Skip to main content

bamboo_infrastructure/config/
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::{OnceLock, RwLock};
57
58use crate::keyword_masking::KeywordMaskingConfig;
59use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
60use bamboo_domain::tool_names::normalize_tool_ref;
61use bamboo_domain::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/// Default work area configuration.
87///
88/// Allows Bamboo to operate without an explicit initial workspace while still
89/// providing a stable fallback directory for relative-path tool execution.
90#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
91pub struct DefaultWorkAreaConfig {
92    /// Optional default filesystem path used when a session has no active workspace.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub path: Option<String>,
95}
96
97/// Access control configuration for password-based UI/API gating.
98#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
99pub struct AccessControlConfig {
100    /// Whether password protection is enabled.
101    #[serde(default)]
102    pub password_enabled: bool,
103    /// Password hash (hex-encoded). Never expose via API.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub password_hash: Option<String>,
106    /// Salt used for hashing (hex-encoded). Never expose via API.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub password_salt: Option<String>,
109    /// Last update timestamp for auditing / debugging.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub updated_at: Option<String>,
112}
113
114/// Memory and background summarization configuration.
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116pub struct MemoryConfig {
117    /// Optional dedicated model for memory/session summarization and reflection.
118    /// Falls back to the provider fast model when unset.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub background_model: Option<String>,
121    /// Whether lightweight automatic Dream-style consolidation should run in the background.
122    #[serde(default)]
123    pub auto_dream_enabled: bool,
124    /// Whether project durable-memory index injection is enabled for the main prompt.
125    #[serde(
126        default = "default_true_memory_project_prompt_injection",
127        alias = "memory_project_prompt_injection"
128    )]
129    pub project_prompt_injection: bool,
130    /// Whether automatic relevant durable-memory recall is enabled for the main prompt.
131    #[serde(
132        default = "default_true_memory_relevant_recall",
133        alias = "memory_relevant_recall"
134    )]
135    pub relevant_recall: bool,
136    /// Whether relevant durable-memory recall should rerank lexical shortlist candidates
137    /// using the configured memory/background model.
138    #[serde(default, alias = "memory_relevant_recall_rerank")]
139    pub relevant_recall_rerank: bool,
140    /// Whether Dream prompt injection should prefer project Dream and only use global Dream as fallback.
141    #[serde(
142        default = "default_true_memory_project_first_dream",
143        alias = "memory_project_first_dream"
144    )]
145    pub project_first_dream: bool,
146    /// Whether Dream generation should refine from the existing notebook when present.
147    ///
148    /// This rolls out cumulative/refining Dream synthesis behind an explicit opt-in flag.
149    #[serde(default, alias = "memory_dream_refine_mode")]
150    pub dream_refine_mode: bool,
151}
152
153impl Default for MemoryConfig {
154    fn default() -> Self {
155        Self {
156            background_model: None,
157            auto_dream_enabled: false,
158            project_prompt_injection: default_true_memory_project_prompt_injection(),
159            relevant_recall: default_true_memory_relevant_recall(),
160            relevant_recall_rerank: false,
161            project_first_dream: default_true_memory_project_first_dream(),
162            dream_refine_mode: false,
163        }
164    }
165}
166
167fn default_true_memory_project_prompt_injection() -> bool {
168    true
169}
170
171fn default_true_memory_relevant_recall() -> bool {
172    true
173}
174
175fn default_true_memory_project_first_dream() -> bool {
176    true
177}
178
179/// Main configuration structure for Bamboo agent
180///
181/// Contains all settings needed to run the agent, including provider credentials,
182/// proxy settings, model selection, and server configuration.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct Config {
185    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
186    #[serde(default)]
187    pub http_proxy: String,
188    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
189    #[serde(default)]
190    pub https_proxy: String,
191    /// Proxy authentication credentials
192    ///
193    /// Note: this is kept in-memory only. On disk we store `proxy_auth_encrypted`.
194    #[serde(skip_serializing)]
195    pub proxy_auth: Option<ProxyAuth>,
196    /// Encrypted proxy authentication credentials (nonce:ciphertext)
197    ///
198    /// This is the at-rest storage representation. When present, Bamboo will
199    /// decrypt it into `proxy_auth` at load time.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub proxy_auth_encrypted: Option<String>,
202    /// Deprecated: Use `providers.copilot.headless_auth` instead
203    #[serde(default)]
204    pub headless_auth: bool,
205
206    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
207    #[serde(default = "default_provider")]
208    pub provider: String,
209
210    /// Default model assignments (used when features.provider_model_ref is enabled).
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub defaults: Option<DefaultsConfig>,
213
214    /// Provider-specific configurations (legacy, single-instance per type).
215    #[serde(default)]
216    pub providers: ProviderConfigs,
217
218    /// Multi-instance provider configurations keyed by instance id.
219    ///
220    /// When `provider_instances` is non-empty, the registry and router prefer
221    /// instance ids as routing keys. Legacy `providers` / `provider` fields are
222    /// still supported for backward compatibility; see
223    /// [`Config::synthesize_legacy_instances`].
224    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
225    pub provider_instances: HashMap<String, ProviderInstanceConfig>,
226
227    /// The default provider instance id used when a request does not specify one.
228    ///
229    /// When set, this takes precedence over the legacy `provider` field.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub default_provider_instance: Option<String>,
232
233    /// HTTP server configuration
234    #[serde(default)]
235    pub server: ServerConfig,
236
237    /// Global keyword masking configuration.
238    ///
239    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
240    #[serde(default)]
241    pub keyword_masking: KeywordMaskingConfig,
242
243    /// Anthropic model mapping configuration.
244    ///
245    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
246    #[serde(default)]
247    pub anthropic_model_mapping: AnthropicModelMapping,
248
249    /// Gemini model mapping configuration.
250    ///
251    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
252    #[serde(default)]
253    pub gemini_model_mapping: GeminiModelMapping,
254
255    /// Request preflight hooks.
256    ///
257    /// These hooks can inspect and rewrite outgoing requests before they are sent upstream
258    /// (e.g. image fallback behavior for text-only models).
259    #[serde(default)]
260    pub hooks: HooksConfig,
261
262    /// Global tool toggles.
263    ///
264    /// Any tool listed in `disabled` is omitted from the tool schemas sent to the LLM.
265    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
266    pub tools: ToolsConfig,
267
268    /// Global skill toggles.
269    ///
270    /// Any skill listed in `disabled` is excluded from skill context construction and
271    /// cannot be loaded through the skill runtime tools.
272    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
273    pub skills: SkillsConfig,
274
275    /// User-managed environment variables injected into Bash tool processes.
276    ///
277    /// Secret entries are encrypted at rest; plaintext values are hydrated in memory.
278    #[serde(default, skip_serializing_if = "Vec::is_empty")]
279    pub env_vars: Vec<EnvVarEntry>,
280
281    /// Default work area used when a session has no explicit active workspace.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub default_work_area: Option<DefaultWorkAreaConfig>,
284
285    /// Access control / password gate configuration.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub access_control: Option<AccessControlConfig>,
288
289    /// Feature flags for incremental rollout.
290    #[serde(default)]
291    pub features: FeatureFlags,
292
293    /// Memory/background summarization settings.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub memory: Option<MemoryConfig>,
296
297    /// MCP server configuration.
298    ///
299    /// Previously persisted in `mcp.json` (now unified into `config.json`).
300    // On disk we use the mainstream `mcpServers` key (matching Claude Desktop / MCP ecosystem
301    // conventions). We still accept the legacy `mcp` key for backward compatibility.
302    #[serde(default, rename = "mcpServers", alias = "mcp")]
303    pub mcp: bamboo_domain::mcp_config::McpConfig,
304
305    /// Extension fields stored at the root of `config.json`.
306    ///
307    /// This keeps the config forward-compatible and allows unrelated subsystems
308    /// (e.g. setup UI state) to persist their own keys without getting dropped by
309    /// typed (de)serialization.
310    #[serde(default, flatten)]
311    pub extra: BTreeMap<String, Value>,
312}
313
314/// Container for provider-specific configurations
315///
316/// Each field is optional, allowing users to configure only the providers they need.
317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
318pub struct ProviderConfigs {
319    /// OpenAI provider configuration
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub openai: Option<OpenAIConfig>,
322    /// Anthropic provider configuration
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub anthropic: Option<AnthropicConfig>,
325    /// Google Gemini provider configuration
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub gemini: Option<GeminiConfig>,
328    /// GitHub Copilot provider configuration
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub copilot: Option<CopilotConfig>,
331    /// Bodhi proxy provider configuration
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub bodhi: Option<BodhiConfig>,
334
335    /// Preserve unknown provider keys (forward compatibility).
336    #[serde(default, flatten)]
337    pub extra: BTreeMap<String, Value>,
338}
339
340/// Feature flags for incremental rollout of new subsystems.
341#[derive(Debug, Clone, Default, Serialize, Deserialize)]
342pub struct FeatureFlags {
343    /// Enable the ProviderModelRef system (multi-provider + unified model selection).
344    #[serde(default)]
345    pub provider_model_ref: bool,
346    /// Enable MiniLoop-based complexity evaluation and dynamic per-round model switching.
347    #[serde(default)]
348    pub dynamic_model_routing: bool,
349}
350
351/// Default model assignments for specific capabilities.
352///
353/// Used when `features.provider_model_ref` is enabled.
354#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
355pub struct DefaultsConfig {
356    pub chat: bamboo_domain::ProviderModelRef,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub fast: Option<bamboo_domain::ProviderModelRef>,
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub task_summary: Option<bamboo_domain::ProviderModelRef>,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub vision: Option<bamboo_domain::ProviderModelRef>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub memory_background: Option<bamboo_domain::ProviderModelRef>,
365    /// Model for planning/coordination tasks (task decomposition, architecture).
366    /// Falls back to `chat` when unset.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub planning: Option<bamboo_domain::ProviderModelRef>,
369    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
370    /// Falls back to `fast` when unset.
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub search: Option<bamboo_domain::ProviderModelRef>,
373    /// Model for code review tasks.
374    /// Falls back to `chat` when unset.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub code_review: Option<bamboo_domain::ProviderModelRef>,
377    /// Default model for child SubAgent runs.
378    /// Falls back to `fast`, then `chat` when unset.
379    #[serde(
380        default,
381        skip_serializing_if = "Option::is_none",
382        alias = "sub_session"
383    )]
384    pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
385    /// Per-subagent-type model overrides.
386    /// Key = subagent_type (e.g. "researcher", "coder"), Value = ProviderModelRef.
387    /// Falls back to `chat` when no match is found for a given type.
388    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
389    pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
390}
391
392/// Request hook configuration.
393#[derive(Debug, Clone, Default, Serialize, Deserialize)]
394pub struct HooksConfig {
395    /// Image fallback behavior for OpenAI-compatible requests (chat/responses).
396    #[serde(default)]
397    pub image_fallback: ImageFallbackHookConfig,
398}
399
400/// Request override configuration for provider-specific HTTP behavior.
401///
402/// Overrides are merged in this order (later wins):
403/// 1. `common`
404/// 2. `endpoints[endpoint]`
405/// 3. matching `rules` (sorted by specificity)
406#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
407pub struct RequestOverridesConfig {
408    /// Overrides applied to all endpoints.
409    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
410    pub common: RequestScopeOverride,
411    /// Endpoint-specific overrides (`chat_completions`, `responses`, `messages`, etc.).
412    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
413    pub endpoints: BTreeMap<String, RequestScopeOverride>,
414    /// Model-conditional overrides.
415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
416    pub rules: Vec<ModelRequestRule>,
417}
418
419/// A conditional override rule matching a model pattern.
420#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
421pub struct ModelRequestRule {
422    /// Model pattern (exact: `gpt-4o`, prefix wildcard: `gpt-5*`).
423    pub model_pattern: String,
424    /// Optional endpoint constraint.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub endpoint: Option<String>,
427    /// Overrides applied when this rule matches.
428    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
429    pub scope: RequestScopeOverride,
430}
431
432/// Request overrides applied in a specific scope.
433#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
434pub struct RequestScopeOverride {
435    /// Extra or overridden HTTP headers.
436    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
437    pub headers: BTreeMap<String, TemplateExpr>,
438    /// JSON body patch operations.
439    #[serde(default, skip_serializing_if = "Vec::is_empty")]
440    pub body_patch: Vec<BodyPatch>,
441}
442
443impl RequestScopeOverride {
444    pub fn is_empty(&self) -> bool {
445        self.headers.is_empty() && self.body_patch.is_empty()
446    }
447}
448
449/// Body patch operation.
450#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
451pub struct BodyPatch {
452    /// Target path (`foo.bar.0` or `/foo/bar/0`).
453    pub path: String,
454    /// Operation type.
455    #[serde(default)]
456    pub op: BodyPatchOp,
457    /// Value for `set` operation.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub value: Option<PatchValue>,
460}
461
462/// Supported body patch operations.
463#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
464#[serde(rename_all = "snake_case")]
465pub enum BodyPatchOp {
466    #[default]
467    Set,
468    Remove,
469}
470
471/// Body patch value: either a template expression or a raw JSON value.
472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
473#[serde(untagged)]
474pub enum PatchValue {
475    Template(TemplateExpr),
476    Json(Value),
477}
478
479/// String template expression used by headers/body patch values.
480#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
481#[serde(untagged)]
482pub enum TemplateExpr {
483    /// Shorthand literal value.
484    Literal(String),
485    /// Structured template expression.
486    Structured(TemplateExprSpec),
487}
488
489/// Structured template expression.
490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
491#[serde(tag = "type", rename_all = "snake_case")]
492pub enum TemplateExprSpec {
493    /// Literal string value.
494    Literal { value: String },
495    /// Reference a value from Bamboo env vars.
496    EnvRef {
497        name: String,
498        #[serde(default, skip_serializing_if = "Option::is_none")]
499        fallback: Option<String>,
500    },
501    /// Generate a runtime value.
502    Generated { generator: GeneratedValue },
503    /// Format string with placeholders (`{env:NAME}`, `{uuid}`, `{unix_ms}`).
504    Format { template: String },
505}
506
507/// Supported generated value kinds.
508#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
509#[serde(rename_all = "snake_case")]
510pub enum GeneratedValue {
511    Uuid,
512    UnixMs,
513}
514
515/// Global tool toggle configuration.
516#[derive(Debug, Clone, Default, Serialize, Deserialize)]
517pub struct ToolsConfig {
518    /// Tool names that are disabled globally.
519    #[serde(default, skip_serializing_if = "Vec::is_empty")]
520    pub disabled: Vec<String>,
521}
522
523impl ToolsConfig {
524    fn is_empty(&self) -> bool {
525        self.disabled.is_empty()
526    }
527}
528
529/// Global skill toggle configuration.
530#[derive(Debug, Clone, Default, Serialize, Deserialize)]
531pub struct SkillsConfig {
532    /// Skill IDs that are disabled globally.
533    #[serde(default, skip_serializing_if = "Vec::is_empty")]
534    pub disabled: Vec<String>,
535}
536
537impl SkillsConfig {
538    fn is_empty(&self) -> bool {
539        self.disabled.is_empty()
540    }
541}
542
543/// When a request contains image parts but the effective provider path is text-only,
544/// we can either:
545/// - error fast (preferred for strict setups), or
546/// - degrade gracefully by replacing images with a placeholder text.
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct ImageFallbackHookConfig {
549    #[serde(default = "default_true_hooks")]
550    pub enabled: bool,
551
552    /// "placeholder" (default) or "error"
553    #[serde(default = "default_image_fallback_mode")]
554    pub mode: String,
555}
556
557impl Default for ImageFallbackHookConfig {
558    fn default() -> Self {
559        Self {
560            enabled: default_true_hooks(),
561            mode: default_image_fallback_mode(),
562        }
563    }
564}
565
566fn default_image_fallback_mode() -> String {
567    "placeholder".to_string()
568}
569
570fn default_true_hooks() -> bool {
571    // Default to disabled so image inputs are preserved unless the user explicitly
572    // opts into fallback rewriting (placeholder/error/ocr).
573    false
574}
575
576/// OpenAI provider configuration
577///
578/// # Example
579///
580/// ```json
581/// "openai": {
582///   "api_key": "sk-...",
583///   "base_url": "https://api.openai.com/v1",
584///   "model": "gpt-4"
585/// }
586/// ```
587#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct OpenAIConfig {
589    /// OpenAI API key (plaintext, in-memory only).
590    ///
591    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
592    #[serde(default, skip_serializing)]
593    pub api_key: String,
594    /// Encrypted OpenAI API key (nonce:ciphertext).
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub api_key_encrypted: Option<String>,
597    /// Custom API base URL (for Azure or self-hosted deployments)
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub base_url: Option<String>,
600    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
601    #[serde(skip_serializing_if = "Option::is_none")]
602    pub model: Option<String>,
603    /// Fast/cheap model for lightweight tasks (title generation and summarization).
604    /// Falls back to `model` when not set.
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub fast_model: Option<String>,
607    /// Vision-capable model for image understanding tasks.
608    /// Falls back to `model` when not set.
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub vision_model: Option<String>,
611    /// Default reasoning effort for OpenAI requests.
612    #[serde(skip_serializing_if = "Option::is_none")]
613    pub reasoning_effort: Option<ReasoningEffort>,
614
615    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
616    ///
617    /// Example:
618    /// ```json
619    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
620    /// ```
621    #[serde(default, skip_serializing_if = "Vec::is_empty")]
622    pub responses_only_models: Vec<String>,
623    /// Optional request overrides (headers/body patches/model rules).
624    #[serde(default, skip_serializing_if = "Option::is_none")]
625    pub request_overrides: Option<RequestOverridesConfig>,
626
627    /// Preserve unknown keys under `providers.openai`.
628    #[serde(default, flatten)]
629    pub extra: BTreeMap<String, Value>,
630}
631
632/// Anthropic provider configuration
633///
634/// # Example
635///
636/// ```json
637/// "anthropic": {
638///   "api_key": "sk-ant-...",
639///   "model": "claude-3-5-sonnet-20241022",
640///   "max_tokens": 4096
641/// }
642/// ```
643#[derive(Debug, Clone, Serialize, Deserialize)]
644pub struct AnthropicConfig {
645    /// Anthropic API key (plaintext, in-memory only).
646    ///
647    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
648    #[serde(default, skip_serializing)]
649    pub api_key: String,
650    /// Encrypted Anthropic API key (nonce:ciphertext).
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub api_key_encrypted: Option<String>,
653    /// Custom API base URL
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub base_url: Option<String>,
656    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub model: Option<String>,
659    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
660    /// Falls back to `model` when not set.
661    #[serde(default, skip_serializing_if = "Option::is_none")]
662    pub fast_model: Option<String>,
663    /// Vision-capable model for image understanding tasks.
664    /// Falls back to `model` when not set.
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub vision_model: Option<String>,
667    /// Maximum tokens in model response
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub max_tokens: Option<u32>,
670    /// Default reasoning effort for Anthropic requests.
671    #[serde(skip_serializing_if = "Option::is_none")]
672    pub reasoning_effort: Option<ReasoningEffort>,
673    /// Optional request overrides (headers/body patches/model rules).
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub request_overrides: Option<RequestOverridesConfig>,
676
677    /// Preserve unknown keys under `providers.anthropic`.
678    #[serde(default, flatten)]
679    pub extra: BTreeMap<String, Value>,
680}
681
682/// Google Gemini provider configuration
683///
684/// # Example
685///
686/// ```json
687/// "gemini": {
688///   "api_key": "AIza...",
689///   "model": "gemini-2.0-flash-exp"
690/// }
691/// ```
692#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct GeminiConfig {
694    /// Google AI API key (plaintext, in-memory only).
695    ///
696    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
697    #[serde(default, skip_serializing)]
698    pub api_key: String,
699    /// Encrypted Google AI API key (nonce:ciphertext).
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub api_key_encrypted: Option<String>,
702    /// Custom API base URL
703    #[serde(skip_serializing_if = "Option::is_none")]
704    pub base_url: Option<String>,
705    /// Default model to use (e.g., "gemini-2.0-flash-exp")
706    #[serde(skip_serializing_if = "Option::is_none")]
707    pub model: Option<String>,
708    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
709    /// Falls back to `model` when not set.
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub fast_model: Option<String>,
712    /// Vision-capable model for image understanding tasks.
713    /// Falls back to `model` when not set.
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub vision_model: Option<String>,
716    /// Default reasoning effort for Gemini requests.
717    #[serde(skip_serializing_if = "Option::is_none")]
718    pub reasoning_effort: Option<ReasoningEffort>,
719    /// Optional request overrides (headers/body patches/model rules).
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub request_overrides: Option<RequestOverridesConfig>,
722
723    /// Preserve unknown keys under `providers.gemini`.
724    #[serde(default, flatten)]
725    pub extra: BTreeMap<String, Value>,
726}
727
728/// GitHub Copilot provider configuration
729///
730/// # Example
731///
732/// ```json
733/// "copilot": {
734///   "enabled": true,
735///   "headless_auth": false,
736///   "model": "gpt-4o"
737/// }
738/// ```
739#[derive(Debug, Clone, Default, Serialize, Deserialize)]
740pub struct CopilotConfig {
741    /// Whether Copilot provider is enabled
742    #[serde(default)]
743    pub enabled: bool,
744    /// Print login URL to console instead of opening browser
745    #[serde(default)]
746    pub headless_auth: bool,
747    /// Default model to use for Copilot (used when clients request the "default" model)
748    #[serde(skip_serializing_if = "Option::is_none")]
749    pub model: Option<String>,
750    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
751    /// Falls back to `model` when not set.
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub fast_model: Option<String>,
754    /// Vision-capable model for image understanding tasks.
755    /// Falls back to `model` when not set.
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub vision_model: Option<String>,
758    /// Default reasoning effort for Copilot requests.
759    #[serde(skip_serializing_if = "Option::is_none")]
760    pub reasoning_effort: Option<ReasoningEffort>,
761
762    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
763    ///
764    /// This is useful for newer Copilot models that only support Responses-style requests.
765    ///
766    /// Example:
767    /// ```json
768    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
769    /// ```
770    #[serde(default, skip_serializing_if = "Vec::is_empty")]
771    pub responses_only_models: Vec<String>,
772    /// Optional request overrides (headers/body patches/model rules).
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub request_overrides: Option<RequestOverridesConfig>,
775
776    /// Preserve unknown keys under `providers.copilot`.
777    #[serde(default, flatten)]
778    pub extra: BTreeMap<String, Value>,
779}
780
781/// Bodhi proxy provider configuration.
782///
783/// Routes LLM requests through a bodhi-server instance so that raw provider
784/// API keys never reach the client.
785#[derive(Debug, Clone, Serialize, Deserialize)]
786pub struct BodhiConfig {
787    /// Bodhi server API key (e.g. "bhi_sk_xxx").  In-memory only.
788    #[serde(default, skip_serializing)]
789    pub api_key: String,
790    /// Encrypted form of the API key stored on disk.
791    #[serde(default, skip_serializing_if = "Option::is_none")]
792    pub api_key_encrypted: Option<String>,
793    /// Bodhi server base URL.
794    #[serde(skip_serializing_if = "Option::is_none")]
795    pub base_url: Option<String>,
796    /// Which upstream provider to route through bodhi ("openai", "anthropic", "gemini").
797    #[serde(skip_serializing_if = "Option::is_none")]
798    pub target_provider: Option<String>,
799    /// Default reasoning effort.
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub reasoning_effort: Option<ReasoningEffort>,
802
803    /// Preserve unknown keys.
804    #[serde(default, flatten)]
805    pub extra: BTreeMap<String, Value>,
806}
807
808/// Returns the default provider name ("anthropic")
809fn default_provider() -> String {
810    "anthropic".to_string()
811}
812
813// ─── Provider Instance Configuration ──────────────────────────────────
814
815/// Configuration for a single provider instance.
816///
817/// Multiple instances of the same provider type (e.g. two OpenAI accounts)
818/// can coexist. Each instance is identified by a stable `instance_id` that
819/// is used as the routing key in [`ProviderModelRef::provider`] and the
820/// provider registry.
821///
822/// # Example (config.json)
823///
824/// ```json
825/// {
826///   "provider_instances": {
827///     "openai-work": {
828///       "provider_type": "openai",
829///       "label": "OpenAI (Work)",
830///       "api_key": "sk-...",
831///       "model": "gpt-4o"
832///     },
833///     "openai-personal": {
834///       "provider_type": "openai",
835///       "label": "OpenAI (Personal)",
836///       "api_key": "sk-...",
837///       "base_url": "https://api.openai.com/v1",
838///       "model": "gpt-4o-mini"
839///     }
840///   },
841///   "default_provider_instance": "openai-work"
842/// }
843/// ```
844#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
845pub struct ProviderInstanceConfig {
846    /// Which provider backend this instance targets.
847    ///
848    /// Must be one of [`AVAILABLE_PROVIDERS`]: `"openai"`, `"anthropic"`,
849    /// `"gemini"`, `"copilot"`, `"bodhi"`.
850    pub provider_type: String,
851
852    /// Human-readable label shown in the UI / catalog.
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    pub label: Option<String>,
855
856    /// API key (plaintext in memory, encrypted at rest via `api_key_encrypted`).
857    #[serde(default, skip_serializing)]
858    pub api_key: String,
859
860    /// Encrypted API key (nonce:ciphertext). Written to disk; decrypted into
861    /// `api_key` on load.
862    #[serde(default, skip_serializing_if = "Option::is_none")]
863    pub api_key_encrypted: Option<String>,
864
865    /// Custom base URL override.
866    #[serde(default, skip_serializing_if = "Option::is_none")]
867    pub base_url: Option<String>,
868
869    /// Default chat model for this instance.
870    #[serde(default, skip_serializing_if = "Option::is_none")]
871    pub model: Option<String>,
872
873    /// Fast/cheap model for lightweight tasks.
874    #[serde(default, skip_serializing_if = "Option::is_none")]
875    pub fast_model: Option<String>,
876
877    /// Vision-capable model.
878    #[serde(default, skip_serializing_if = "Option::is_none")]
879    pub vision_model: Option<String>,
880
881    /// Default reasoning effort.
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
884
885    /// Models that must use the Responses API upstream (OpenAI only).
886    #[serde(default, skip_serializing_if = "Vec::is_empty")]
887    pub responses_only_models: Vec<String>,
888
889    /// Optional request overrides (headers/body patches/model rules).
890    #[serde(default, skip_serializing_if = "Option::is_none")]
891    pub request_overrides: Option<RequestOverridesConfig>,
892
893    /// Whether this instance is enabled. Disabled instances are skipped
894    /// during registry construction.
895    #[serde(default = "default_true")]
896    pub enabled: bool,
897
898    /// Provider-type-specific extra fields preserved through (de)serialization.
899    #[serde(default, flatten)]
900    pub extra: BTreeMap<String, Value>,
901}
902
903fn default_true() -> bool {
904    true
905}
906
907/// Returns the default server port (9562)
908fn default_port() -> u16 {
909    9562
910}
911
912/// Returns the default bind address (127.0.0.1)
913fn default_bind() -> String {
914    "127.0.0.1".to_string()
915}
916
917/// Returns the default worker count (10)
918fn default_workers() -> usize {
919    10
920}
921
922/// Returns the default data directory (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
923fn default_data_dir() -> PathBuf {
924    super::paths::bamboo_dir()
925}
926
927/// HTTP server configuration
928#[derive(Debug, Clone, Serialize, Deserialize)]
929pub struct ServerConfig {
930    /// Port to listen on
931    #[serde(default = "default_port")]
932    pub port: u16,
933
934    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
935    #[serde(default = "default_bind")]
936    pub bind: String,
937
938    /// Static files directory (for Docker mode)
939    pub static_dir: Option<PathBuf>,
940
941    /// Worker count for Actix-web
942    #[serde(default = "default_workers")]
943    pub workers: usize,
944
945    /// Preserve unknown keys under `server`.
946    #[serde(default, flatten)]
947    pub extra: BTreeMap<String, Value>,
948}
949
950impl Default for ServerConfig {
951    fn default() -> Self {
952        Self {
953            port: default_port(),
954            bind: default_bind(),
955            static_dir: None,
956            workers: default_workers(),
957            extra: BTreeMap::new(),
958        }
959    }
960}
961
962/// Proxy authentication credentials
963#[derive(Debug, Clone, Serialize, Deserialize)]
964pub struct ProxyAuth {
965    /// Proxy username
966    pub username: String,
967    /// Proxy password
968    pub password: String,
969}
970
971/// Parse a boolean value from environment variable strings
972///
973/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
974fn parse_bool_env(value: &str) -> bool {
975    matches!(
976        value.trim().to_ascii_lowercase().as_str(),
977        "1" | "true" | "yes" | "y" | "on"
978    )
979}
980
981fn expand_user_path(value: &str) -> PathBuf {
982    let trimmed = value.trim();
983    if let Some(rest) = trimmed.strip_prefix("~/") {
984        if let Some(home) = dirs::home_dir() {
985            return home.join(rest);
986        }
987    }
988    PathBuf::from(trimmed)
989}
990
991impl Default for Config {
992    fn default() -> Self {
993        Self::new()
994    }
995}
996
997/// Prompt-safe snapshot of configured env vars.
998#[derive(Debug, Clone, PartialEq, Eq)]
999pub struct PromptSafeEnvVarEntry {
1000    pub name: String,
1001    pub secret: bool,
1002    pub description: Option<String>,
1003}
1004
1005/// Global cache of user-managed env vars for injection into child processes.
1006///
1007/// Updated whenever the config is loaded or reloaded via [`Config::publish_env_vars`].
1008static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
1009
1010static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
1011
1012fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
1013    ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
1014}
1015
1016fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
1017    PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
1018}
1019
1020impl Config {
1021    /// Load configuration from file with environment variable overrides
1022    ///
1023    /// Configuration loading order:
1024    /// 1. Try loading from `config.json` (`{data_dir}/config.json`)
1025    /// 2. Use defaults
1026    /// 3. Apply environment variable overrides (highest priority)
1027    ///
1028    /// # Environment Variables
1029    ///
1030    /// - `BAMBOO_PORT`: Override server port
1031    /// - `BAMBOO_BIND`: Override bind address
1032    /// - `BAMBOO_DATA_DIR`: Override data directory
1033    /// - `BAMBOO_PROVIDER`: Override default provider
1034    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
1035    /// - `BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION`: Override project durable-memory index prompt injection
1036    /// - `BAMBOO_MEMORY_RELEVANT_RECALL`: Override relevant durable-memory recall prompt injection
1037    /// - `BAMBOO_MEMORY_RELEVANT_RECALL_RERANK`: Override model-based relevant recall reranking
1038    /// - `BAMBOO_MEMORY_PROJECT_FIRST_DREAM`: Override project-first Dream prompt behavior
1039    pub fn new() -> Self {
1040        Self::from_data_dir(None)
1041    }
1042
1043    /// Load configuration from a specific data directory
1044    ///
1045    /// # Arguments
1046    ///
1047    /// * `data_dir` - Optional data directory path. If None, uses default (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
1048    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
1049        // Determine data_dir early (needed to find config file)
1050        let data_dir = data_dir
1051            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
1052            .unwrap_or_else(default_data_dir);
1053
1054        let config_path = data_dir.join("config.json");
1055
1056        let mut config = if config_path.exists() {
1057            if let Ok(content) = std::fs::read_to_string(&config_path) {
1058                serde_json::from_str::<Config>(&content)
1059                    .map(|mut config| {
1060                        config.hydrate_proxy_auth_from_encrypted();
1061                        config.hydrate_provider_api_keys_from_encrypted();
1062                        config.hydrate_provider_instance_api_keys_from_encrypted();
1063                        config.hydrate_mcp_secrets_from_encrypted();
1064                        config.hydrate_env_vars_from_encrypted();
1065                        config.normalize_tool_settings();
1066                        config.normalize_skill_settings();
1067                        config
1068                    })
1069                    .unwrap_or_else(|e| {
1070                        tracing::warn!("Failed to parse config.json ({}), using defaults", e);
1071                        Self::create_default()
1072                    })
1073            } else {
1074                Self::create_default()
1075            }
1076        } else {
1077            Self::create_default()
1078        };
1079
1080        // Decrypt encrypted proxy auth into in-memory plaintext form.
1081        config.hydrate_proxy_auth_from_encrypted();
1082        // Decrypt encrypted provider API keys into in-memory plaintext form.
1083        config.hydrate_provider_api_keys_from_encrypted();
1084        // Decrypt encrypted provider-instance API keys into in-memory plaintext form.
1085        config.hydrate_provider_instance_api_keys_from_encrypted();
1086        // Decrypt encrypted MCP secrets into in-memory plaintext form.
1087        config.hydrate_mcp_secrets_from_encrypted();
1088        // Decrypt encrypted env vars into in-memory plaintext form.
1089        config.hydrate_env_vars_from_encrypted();
1090        config.normalize_tool_settings();
1091        config.normalize_skill_settings();
1092
1093        // Legacy: `data_dir` is no longer a persisted config field. The data directory is
1094        // derived from runtime (BAMBOO_DATA_DIR or `${HOME}/.bamboo`).
1095        config.extra.remove("data_dir");
1096
1097        // Apply environment variable overrides (highest priority)
1098        if let Ok(port) = std::env::var("BAMBOO_PORT") {
1099            if let Ok(port) = port.parse() {
1100                config.server.port = port;
1101            }
1102        }
1103
1104        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
1105            config.server.bind = bind;
1106        }
1107
1108        // Note: BAMBOO_DATA_DIR already handled above
1109        if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
1110            config.provider = provider;
1111        }
1112
1113        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
1114            config.headless_auth = parse_bool_env(&headless);
1115        }
1116
1117        if let Ok(project_prompt_injection) =
1118            std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
1119        {
1120            let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1121            memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
1122        }
1123
1124        if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
1125            let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1126            memory.relevant_recall = parse_bool_env(&relevant_recall);
1127        }
1128
1129        if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
1130            let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1131            memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
1132        }
1133
1134        if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
1135            let memory = config.memory.get_or_insert_with(MemoryConfig::default);
1136            memory.project_first_dream = parse_bool_env(&project_first_dream);
1137        }
1138
1139        // Publish env vars to the global cache so Bash tools can inject them.
1140        config.publish_env_vars();
1141
1142        config
1143    }
1144
1145    /// Get the effective default model for the currently active provider.
1146    ///
1147    /// When `features.provider_model_ref` is enabled, reads from `defaults.chat`
1148    /// before falling back to legacy provider-specific config.
1149    ///
1150    /// Note: for most providers this is a required config value (returns None when absent).
1151    /// Copilot has a built-in fallback when no model is configured.
1152    pub fn get_model(&self) -> Option<String> {
1153        if self.features.provider_model_ref {
1154            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
1155                return Some(model_ref.model.clone());
1156            }
1157        }
1158        match self.provider.as_str() {
1159            "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
1160            "anthropic" => self
1161                .providers
1162                .anthropic
1163                .as_ref()
1164                .and_then(|c| c.model.clone()),
1165            "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
1166            "copilot" => Some(
1167                self.providers
1168                    .copilot
1169                    .as_ref()
1170                    .and_then(|c| c.model.clone())
1171                    .unwrap_or_else(|| "gpt-4o".to_string()),
1172            ),
1173            _ => None,
1174        }
1175    }
1176
1177    /// Get the fast/cheap model for the currently active provider.
1178    ///
1179    /// When `features.provider_model_ref` is enabled, reads from `defaults.fast`
1180    /// before falling back to legacy provider-specific config.
1181    ///
1182    /// Used for lightweight tasks like title generation and summarization.
1183    /// Falls back to `get_model()` when no fast_model is configured.
1184    pub fn get_fast_model(&self) -> Option<String> {
1185        if self.features.provider_model_ref {
1186            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
1187                return Some(model_ref.model.clone());
1188            }
1189        }
1190        let fast = match self.provider.as_str() {
1191            "openai" => self
1192                .providers
1193                .openai
1194                .as_ref()
1195                .and_then(|c| c.fast_model.clone()),
1196            "anthropic" => self
1197                .providers
1198                .anthropic
1199                .as_ref()
1200                .and_then(|c| c.fast_model.clone()),
1201            "gemini" => self
1202                .providers
1203                .gemini
1204                .as_ref()
1205                .and_then(|c| c.fast_model.clone()),
1206            "copilot" => self
1207                .providers
1208                .copilot
1209                .as_ref()
1210                .and_then(|c| c.fast_model.clone()),
1211            _ => None,
1212        };
1213        fast.or_else(|| self.get_model())
1214    }
1215
1216    /// Get the configured task summarization model.
1217    ///
1218    /// When `features.provider_model_ref` is enabled, reads from
1219    /// `defaults.task_summary` before falling back through
1220    /// `defaults.memory_background` → `defaults.fast` → `defaults.chat`.
1221    ///
1222    /// This is used for conversation/task summarization and context compression.
1223    pub fn get_task_summary_model(&self) -> Option<String> {
1224        if self.features.provider_model_ref {
1225            if let Some(model_ref) = self
1226                .defaults
1227                .as_ref()
1228                .and_then(|d| d.task_summary.as_ref())
1229                .or_else(|| {
1230                    self.defaults
1231                        .as_ref()
1232                        .and_then(|d| d.memory_background.as_ref())
1233                })
1234                .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
1235                .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
1236            {
1237                return Some(model_ref.model.clone());
1238            }
1239        }
1240
1241        self.get_memory_background_model()
1242            .or_else(|| self.get_model())
1243    }
1244
1245    /// Get the configured memory/background summarization model.
1246    ///
1247    /// When `features.provider_model_ref` is enabled, reads from
1248    /// `defaults.memory_background` before falling back to legacy config.
1249    ///
1250    /// Falls back to the provider fast model when no background model is
1251    /// configured or resolves to an empty string.
1252    ///
1253    /// IMPORTANT: this intentionally does **not** fall back to the main
1254    /// interaction model. Memory compaction / reflection should be skipped or
1255    /// fail loudly when no background/fast model is configured.
1256    pub fn get_memory_background_model(&self) -> Option<String> {
1257        if self.features.provider_model_ref {
1258            if let Some(model_ref) = self
1259                .defaults
1260                .as_ref()
1261                .and_then(|d| d.memory_background.as_ref())
1262            {
1263                return Some(model_ref.model.clone());
1264            }
1265            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
1266                return Some(model_ref.model.clone());
1267            }
1268        }
1269        let configured = self
1270            .memory
1271            .as_ref()
1272            .and_then(|memory| memory.background_model.as_ref())
1273            .map(|value| value.trim())
1274            .filter(|value| !value.is_empty())
1275            .map(ToString::to_string);
1276        configured.or_else(|| match self.provider.as_str() {
1277            "openai" => self
1278                .providers
1279                .openai
1280                .as_ref()
1281                .and_then(|c| c.fast_model.clone()),
1282            "anthropic" => self
1283                .providers
1284                .anthropic
1285                .as_ref()
1286                .and_then(|c| c.fast_model.clone()),
1287            "gemini" => self
1288                .providers
1289                .gemini
1290                .as_ref()
1291                .and_then(|c| c.fast_model.clone()),
1292            "copilot" => self
1293                .providers
1294                .copilot
1295                .as_ref()
1296                .and_then(|c| c.fast_model.clone()),
1297            _ => None,
1298        })
1299    }
1300
1301    /// Resolve the configured default work area path when present.
1302    ///
1303    /// This validates that the configured directory exists, but intentionally
1304    /// returns the stable expanded path rather than the platform-specific
1305    /// canonicalized path. On macOS, `canonicalize()` may rewrite `/var/...`
1306    /// to `/private/var/...`, which is correct at the filesystem layer but
1307    /// undesirable as a user-facing/config-derived workspace path.
1308    pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
1309        let raw = self
1310            .default_work_area
1311            .as_ref()
1312            .and_then(|config| config.path.as_ref())
1313            .map(|value| value.trim())
1314            .filter(|value| !value.is_empty())?;
1315
1316        let candidate = expand_user_path(raw);
1317        if candidate.is_absolute() {
1318            let canonical = std::fs::canonicalize(&candidate).ok();
1319            return canonical
1320                .as_ref()
1321                .filter(|path| path.is_dir())
1322                .map(|_| candidate.clone())
1323                .or_else(|| candidate.is_dir().then_some(candidate));
1324        }
1325
1326        let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
1327        let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
1328        canonical
1329            .as_ref()
1330            .filter(|path| path.is_dir())
1331            .map(|_| from_bamboo_dir.clone())
1332            .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
1333            .or_else(|| candidate.is_dir().then_some(candidate))
1334    }
1335
1336    /// Get the vision-capable model for the currently active provider.
1337    ///
1338    /// Used for image understanding tasks.
1339    /// Falls back to `get_model()` when no vision_model is configured.
1340    pub fn get_vision_model(&self) -> Option<String> {
1341        let vision = match self.provider.as_str() {
1342            "openai" => self
1343                .providers
1344                .openai
1345                .as_ref()
1346                .and_then(|c| c.vision_model.clone()),
1347            "anthropic" => self
1348                .providers
1349                .anthropic
1350                .as_ref()
1351                .and_then(|c| c.vision_model.clone()),
1352            "gemini" => self
1353                .providers
1354                .gemini
1355                .as_ref()
1356                .and_then(|c| c.vision_model.clone()),
1357            "copilot" => self
1358                .providers
1359                .copilot
1360                .as_ref()
1361                .and_then(|c| c.vision_model.clone()),
1362            _ => None,
1363        };
1364        vision.or_else(|| self.get_model())
1365    }
1366
1367    /// Get the default reasoning effort for the currently active provider.
1368    pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
1369        match self.provider.as_str() {
1370            "openai" => self
1371                .providers
1372                .openai
1373                .as_ref()
1374                .and_then(|c| c.reasoning_effort),
1375            "anthropic" => self
1376                .providers
1377                .anthropic
1378                .as_ref()
1379                .and_then(|c| c.reasoning_effort),
1380            "gemini" => self
1381                .providers
1382                .gemini
1383                .as_ref()
1384                .and_then(|c| c.reasoning_effort),
1385            "copilot" => self
1386                .providers
1387                .copilot
1388                .as_ref()
1389                .and_then(|c| c.reasoning_effort),
1390            _ => None,
1391        }
1392    }
1393
1394    /// Get normalized disabled tool names.
1395    pub fn disabled_tool_names(&self) -> BTreeSet<String> {
1396        self.tools
1397            .disabled
1398            .iter()
1399            .map(|name| name.trim())
1400            .filter(|name| !name.is_empty())
1401            .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
1402            .collect()
1403    }
1404
1405    /// Normalize tool settings (trim / dedupe / sort).
1406    pub fn normalize_tool_settings(&mut self) {
1407        self.tools.disabled = self.disabled_tool_names().into_iter().collect();
1408    }
1409
1410    /// Get normalized disabled skill IDs.
1411    pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
1412        self.skills
1413            .disabled
1414            .iter()
1415            .map(|id| id.trim())
1416            .filter(|id| !id.is_empty())
1417            .map(|id| id.to_string())
1418            .collect()
1419    }
1420
1421    /// Normalize skill settings (trim / dedupe / sort).
1422    pub fn normalize_skill_settings(&mut self) {
1423        self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
1424    }
1425
1426    /// Return the effective default provider key.
1427    ///
1428    /// Prefers `default_provider_instance` when set; falls back to the
1429    /// legacy `provider` string.
1430    pub fn effective_default_provider(&self) -> &str {
1431        self.default_provider_instance
1432            .as_deref()
1433            .unwrap_or(&self.provider)
1434    }
1435
1436    /// Whether provider instances are configured (new multi-instance path).
1437    pub fn has_provider_instances(&self) -> bool {
1438        !self.provider_instances.is_empty()
1439    }
1440
1441    /// Populate `proxy_auth` (plaintext) from `proxy_auth_encrypted` if present.
1442    ///
1443    /// Many parts of the code rely on `proxy_auth` being hydrated in-memory so
1444    /// we can re-encrypt deterministically on save without ever persisting
1445    /// plaintext credentials.
1446    pub fn hydrate_proxy_auth_from_encrypted(&mut self) {
1447        if self.proxy_auth.is_some() {
1448            return;
1449        }
1450
1451        // Backward compatibility:
1452        // Older Bodhi/Tauri builds persisted proxy auth as per-scheme encrypted fields:
1453        // `http_proxy_auth_encrypted` / `https_proxy_auth_encrypted`.
1454        //
1455        // Those live under `extra` (flatten) in the unified config. Seed the new
1456        // `proxy_auth_encrypted` field so the rest of the code can stay uniform.
1457        if self
1458            .proxy_auth_encrypted
1459            .as_deref()
1460            .map(|s| s.trim().is_empty())
1461            .unwrap_or(true)
1462        {
1463            let legacy = self
1464                .extra
1465                .get("https_proxy_auth_encrypted")
1466                .and_then(|v| v.as_str())
1467                .or_else(|| {
1468                    self.extra
1469                        .get("http_proxy_auth_encrypted")
1470                        .and_then(|v| v.as_str())
1471                })
1472                .map(|s| s.trim())
1473                .filter(|s| !s.is_empty())
1474                .map(|s| s.to_string());
1475
1476            if let Some(legacy) = legacy {
1477                self.proxy_auth_encrypted = Some(legacy);
1478            }
1479        }
1480
1481        let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
1482            return;
1483        };
1484
1485        match crate::encryption::decrypt(encrypted) {
1486            Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
1487                Ok(auth) => {
1488                    self.proxy_auth = Some(auth);
1489                    // Once hydrated successfully, drop legacy keys so a future save writes only
1490                    // the canonical `proxy_auth_encrypted` field.
1491                    self.extra.remove("http_proxy_auth_encrypted");
1492                    self.extra.remove("https_proxy_auth_encrypted");
1493                }
1494                Err(e) => tracing::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
1495            },
1496            Err(e) => tracing::warn!("Failed to decrypt proxy auth: {}", e),
1497        }
1498    }
1499
1500    /// Refresh `proxy_auth_encrypted` from the current in-memory `proxy_auth`.
1501    ///
1502    /// This is used both when persisting the config to disk and when generating
1503    /// API responses that should never include plaintext proxy credentials.
1504    pub fn refresh_proxy_auth_encrypted(&mut self) -> Result<()> {
1505        // Keep on-disk representation fully derived from the in-memory plaintext:
1506        // - Some(auth)  => always (re-)encrypt and store `proxy_auth_encrypted`
1507        // - None        => remove `proxy_auth_encrypted`
1508        let Some(auth) = self.proxy_auth.as_ref() else {
1509            self.proxy_auth_encrypted = None;
1510            return Ok(());
1511        };
1512
1513        let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
1514        let encrypted =
1515            crate::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
1516        self.proxy_auth_encrypted = Some(encrypted);
1517        Ok(())
1518    }
1519
1520    pub fn hydrate_provider_api_keys_from_encrypted(&mut self) {
1521        if let Some(openai) = self.providers.openai.as_mut() {
1522            if openai.api_key.trim().is_empty() {
1523                if let Some(encrypted) = openai.api_key_encrypted.as_deref() {
1524                    match crate::encryption::decrypt(encrypted) {
1525                        Ok(value) => openai.api_key = value,
1526                        Err(e) => tracing::warn!("Failed to decrypt OpenAI api_key: {}", e),
1527                    }
1528                }
1529            }
1530        }
1531
1532        if let Some(anthropic) = self.providers.anthropic.as_mut() {
1533            if anthropic.api_key.trim().is_empty() {
1534                if let Some(encrypted) = anthropic.api_key_encrypted.as_deref() {
1535                    match crate::encryption::decrypt(encrypted) {
1536                        Ok(value) => anthropic.api_key = value,
1537                        Err(e) => tracing::warn!("Failed to decrypt Anthropic api_key: {}", e),
1538                    }
1539                }
1540            }
1541        }
1542
1543        if let Some(gemini) = self.providers.gemini.as_mut() {
1544            if gemini.api_key.trim().is_empty() {
1545                if let Some(encrypted) = gemini.api_key_encrypted.as_deref() {
1546                    match crate::encryption::decrypt(encrypted) {
1547                        Ok(value) => gemini.api_key = value,
1548                        Err(e) => tracing::warn!("Failed to decrypt Gemini api_key: {}", e),
1549                    }
1550                }
1551            }
1552        }
1553
1554        if let Some(bodhi) = self.providers.bodhi.as_mut() {
1555            if bodhi.api_key.trim().is_empty() {
1556                if let Some(encrypted) = bodhi.api_key_encrypted.as_deref() {
1557                    match crate::encryption::decrypt(encrypted) {
1558                        Ok(value) => bodhi.api_key = value,
1559                        Err(e) => tracing::warn!("Failed to decrypt Bodhi api_key: {}", e),
1560                    }
1561                }
1562            }
1563        }
1564    }
1565
1566    pub fn refresh_provider_api_keys_encrypted(&mut self) -> Result<()> {
1567        if let Some(openai) = self.providers.openai.as_mut() {
1568            let api_key = openai.api_key.trim();
1569            openai.api_key_encrypted = if api_key.is_empty() {
1570                None
1571            } else {
1572                Some(
1573                    crate::encryption::encrypt(api_key)
1574                        .context("Failed to encrypt OpenAI api_key")?,
1575                )
1576            };
1577        }
1578
1579        if let Some(anthropic) = self.providers.anthropic.as_mut() {
1580            let api_key = anthropic.api_key.trim();
1581            anthropic.api_key_encrypted = if api_key.is_empty() {
1582                None
1583            } else {
1584                Some(
1585                    crate::encryption::encrypt(api_key)
1586                        .context("Failed to encrypt Anthropic api_key")?,
1587                )
1588            };
1589        }
1590
1591        if let Some(gemini) = self.providers.gemini.as_mut() {
1592            let api_key = gemini.api_key.trim();
1593            gemini.api_key_encrypted = if api_key.is_empty() {
1594                None
1595            } else {
1596                Some(
1597                    crate::encryption::encrypt(api_key)
1598                        .context("Failed to encrypt Gemini api_key")?,
1599                )
1600            };
1601        }
1602
1603        if let Some(bodhi) = self.providers.bodhi.as_mut() {
1604            let api_key = bodhi.api_key.trim();
1605            bodhi.api_key_encrypted = if api_key.is_empty() {
1606                None
1607            } else {
1608                Some(
1609                    crate::encryption::encrypt(api_key)
1610                        .context("Failed to encrypt Bodhi api_key")?,
1611                )
1612            };
1613        }
1614
1615        Ok(())
1616    }
1617
1618    /// Hydrate plaintext `api_key` fields on provider instances from their
1619    /// encrypted counterparts.
1620    pub fn hydrate_provider_instance_api_keys_from_encrypted(&mut self) {
1621        for (id, instance) in self.provider_instances.iter_mut() {
1622            if instance.api_key.trim().is_empty() {
1623                if let Some(encrypted) = instance.api_key_encrypted.as_deref() {
1624                    match crate::encryption::decrypt(encrypted) {
1625                        Ok(value) => instance.api_key = value,
1626                        Err(e) => {
1627                            tracing::warn!(instance_id = id, "Failed to decrypt api_key: {}", e)
1628                        }
1629                    }
1630                }
1631            }
1632        }
1633    }
1634
1635    /// Re-encrypt all provider instance API keys and write back to
1636    /// `api_key_encrypted`. Used before persisting to disk.
1637    pub fn refresh_provider_instance_api_keys_encrypted(&mut self) -> Result<()> {
1638        for (id, instance) in self.provider_instances.iter_mut() {
1639            let api_key = instance.api_key.trim();
1640            instance.api_key_encrypted = if api_key.is_empty() {
1641                None
1642            } else {
1643                Some(crate::encryption::encrypt(api_key).context(format!(
1644                    "Failed to encrypt api_key for provider instance '{}'",
1645                    id
1646                ))?)
1647            };
1648        }
1649        Ok(())
1650    }
1651
1652    pub fn hydrate_mcp_secrets_from_encrypted(&mut self) {
1653        for server in self.mcp.servers.iter_mut() {
1654            match &mut server.transport {
1655                bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
1656                    if stdio.env_encrypted.is_empty() {
1657                        continue;
1658                    }
1659
1660                    // Avoid borrow-checker gymnastics by iterating a cloned map.
1661                    for (key, encrypted) in stdio.env_encrypted.clone() {
1662                        let should_hydrate = stdio
1663                            .env
1664                            .get(&key)
1665                            .map(|v| v.trim().is_empty())
1666                            .unwrap_or(true);
1667                        if !should_hydrate {
1668                            continue;
1669                        }
1670
1671                        match crate::encryption::decrypt(&encrypted) {
1672                            Ok(value) => {
1673                                stdio.env.insert(key, value);
1674                            }
1675                            Err(e) => tracing::warn!("Failed to decrypt MCP stdio env var: {}", e),
1676                        }
1677                    }
1678                }
1679                bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
1680                    for header in sse.headers.iter_mut() {
1681                        if !header.value.trim().is_empty() {
1682                            continue;
1683                        }
1684                        let Some(encrypted) = header.value_encrypted.as_deref() else {
1685                            continue;
1686                        };
1687                        match crate::encryption::decrypt(encrypted) {
1688                            Ok(value) => header.value = value,
1689                            Err(e) => {
1690                                tracing::warn!("Failed to decrypt MCP SSE header value: {}", e)
1691                            }
1692                        }
1693                    }
1694                }
1695                bamboo_domain::mcp_config::TransportConfig::StreamableHttp(sh) => {
1696                    for header in sh.headers.iter_mut() {
1697                        if !header.value.trim().is_empty() {
1698                            continue;
1699                        }
1700                        let Some(encrypted) = header.value_encrypted.as_deref() else {
1701                            continue;
1702                        };
1703                        match crate::encryption::decrypt(encrypted) {
1704                            Ok(value) => header.value = value,
1705                            Err(e) => {
1706                                tracing::warn!(
1707                                    "Failed to decrypt MCP StreamableHTTP header value: {}",
1708                                    e
1709                                )
1710                            }
1711                        }
1712                    }
1713                }
1714            }
1715        }
1716    }
1717
1718    pub fn refresh_mcp_secrets_encrypted(&mut self) -> Result<()> {
1719        for server in self.mcp.servers.iter_mut() {
1720            match &mut server.transport {
1721                bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
1722                    stdio.env_encrypted.clear();
1723                    for (key, value) in &stdio.env {
1724                        let encrypted = crate::encryption::encrypt(value).with_context(|| {
1725                            format!("Failed to encrypt MCP stdio env var '{key}'")
1726                        })?;
1727                        stdio.env_encrypted.insert(key.clone(), encrypted);
1728                    }
1729                }
1730                bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
1731                    for header in sse.headers.iter_mut() {
1732                        let configured = !header.value.trim().is_empty();
1733                        header.value_encrypted = if !configured {
1734                            None
1735                        } else {
1736                            Some(crate::encryption::encrypt(&header.value).with_context(|| {
1737                                format!("Failed to encrypt MCP SSE header '{}'", header.name)
1738                            })?)
1739                        };
1740                    }
1741                }
1742                bamboo_domain::mcp_config::TransportConfig::StreamableHttp(sh) => {
1743                    for header in sh.headers.iter_mut() {
1744                        let configured = !header.value.trim().is_empty();
1745                        header.value_encrypted = if !configured {
1746                            None
1747                        } else {
1748                            Some(crate::encryption::encrypt(&header.value).with_context(|| {
1749                                format!(
1750                                    "Failed to encrypt MCP StreamableHTTP header '{}'",
1751                                    header.name
1752                                )
1753                            })?)
1754                        };
1755                    }
1756                }
1757            }
1758        }
1759
1760        Ok(())
1761    }
1762
1763    // ── Env vars encryption ─────────────────────────────────────────────
1764
1765    /// Decrypt secret env vars into in-memory plaintext after loading config.
1766    pub fn hydrate_env_vars_from_encrypted(&mut self) {
1767        for entry in &mut self.env_vars {
1768            if !entry.secret {
1769                continue;
1770            }
1771            if !entry.value.trim().is_empty() {
1772                // Already has plaintext (e.g. in-memory update).
1773                continue;
1774            }
1775            let Some(encrypted) = &entry.value_encrypted else {
1776                continue;
1777            };
1778            match crate::encryption::decrypt(encrypted) {
1779                Ok(value) => entry.value = value,
1780                Err(e) => tracing::warn!("Failed to decrypt env var '{}': {}", entry.name, e),
1781            }
1782        }
1783    }
1784
1785    /// Re-encrypt secret env vars before persisting to disk.
1786    pub fn refresh_env_vars_encrypted(&mut self) -> Result<()> {
1787        for entry in &mut self.env_vars {
1788            if entry.secret && !entry.value.trim().is_empty() {
1789                entry.value_encrypted = Some(
1790                    crate::encryption::encrypt(&entry.value)
1791                        .with_context(|| format!("Failed to encrypt env var '{}'", entry.name))?,
1792                );
1793            } else if !entry.secret {
1794                entry.value_encrypted = None;
1795            }
1796        }
1797        Ok(())
1798    }
1799
1800    /// Clear plaintext values for secrets before serialization to disk.
1801    pub fn sanitize_env_vars_for_disk(&mut self) {
1802        for entry in &mut self.env_vars {
1803            if entry.secret {
1804                entry.value = String::new();
1805            }
1806        }
1807    }
1808
1809    /// Build a flat map of all env vars with non-empty values (for process injection).
1810    pub fn env_vars_as_map(&self) -> HashMap<String, String> {
1811        self.env_vars
1812            .iter()
1813            .filter(|e| !e.value.trim().is_empty())
1814            .map(|e| (e.name.clone(), e.value.clone()))
1815            .collect()
1816    }
1817
1818    fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
1819        self.env_vars
1820            .iter()
1821            .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
1822            .map(|entry| PromptSafeEnvVarEntry {
1823                name: entry.name.clone(),
1824                secret: entry.secret,
1825                description: entry
1826                    .description
1827                    .as_ref()
1828                    .map(|value| value.trim().to_string())
1829                    .filter(|value| !value.is_empty()),
1830            })
1831            .collect()
1832    }
1833
1834    /// Update the global env vars cache (called on config load / reload).
1835    pub fn publish_env_vars(&self) {
1836        let map = self.env_vars_as_map();
1837        let mut env_guard = env_vars_cache()
1838            .write()
1839            .unwrap_or_else(|poisoned| poisoned.into_inner());
1840        *env_guard = map;
1841
1842        let prompt_safe = self.prompt_safe_env_vars();
1843        let mut prompt_guard = prompt_safe_env_vars_cache()
1844            .write()
1845            .unwrap_or_else(|poisoned| poisoned.into_inner());
1846        *prompt_guard = prompt_safe;
1847    }
1848
1849    /// Read the current env vars snapshot (called by Bash tool at process spawn time).
1850    pub fn current_env_vars() -> HashMap<String, String> {
1851        env_vars_cache()
1852            .read()
1853            .unwrap_or_else(|poisoned| poisoned.into_inner())
1854            .clone()
1855    }
1856
1857    /// Read the current prompt-safe env var snapshot (names + metadata only; no secret values).
1858    pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
1859        prompt_safe_env_vars_cache()
1860            .read()
1861            .unwrap_or_else(|poisoned| poisoned.into_inner())
1862            .clone()
1863    }
1864
1865    /// Create a default configuration without loading from file
1866    fn create_default() -> Self {
1867        Config {
1868            http_proxy: String::new(),
1869            https_proxy: String::new(),
1870            proxy_auth: None,
1871            proxy_auth_encrypted: None,
1872            headless_auth: false,
1873            provider: default_provider(),
1874            providers: ProviderConfigs::default(),
1875            provider_instances: HashMap::new(),
1876            default_provider_instance: None,
1877            server: ServerConfig::default(),
1878            keyword_masking: KeywordMaskingConfig::default(),
1879            anthropic_model_mapping: AnthropicModelMapping::default(),
1880            gemini_model_mapping: GeminiModelMapping::default(),
1881            hooks: HooksConfig::default(),
1882            tools: ToolsConfig::default(),
1883            skills: SkillsConfig::default(),
1884            env_vars: Vec::new(),
1885            default_work_area: None,
1886            access_control: None,
1887            features: FeatureFlags::default(),
1888            defaults: None,
1889            memory: None,
1890            mcp: bamboo_domain::mcp_config::McpConfig::default(),
1891            extra: BTreeMap::new(),
1892        }
1893    }
1894
1895    /// Get the full server address (bind:port)
1896    pub fn server_addr(&self) -> String {
1897        format!("{}:{}", self.server.bind, self.server.port)
1898    }
1899
1900    /// Save configuration to disk
1901    pub fn save(&self) -> Result<()> {
1902        self.save_to_dir(default_data_dir())
1903    }
1904
1905    /// Save configuration to disk under the provided data directory.
1906    ///
1907    /// Configuration is always stored as `{data_dir}/config.json`.
1908    pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
1909        let path = data_dir.join("config.json");
1910
1911        if let Some(parent) = path.parent() {
1912            std::fs::create_dir_all(parent)
1913                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
1914        }
1915
1916        let mut to_save = self.clone();
1917        // Never persist `data_dir` into config.json (data dir is runtime-derived).
1918        to_save.extra.remove("data_dir");
1919        // Root-level `model` is deprecated; do not persist it.
1920        to_save.extra.remove("model");
1921        to_save.refresh_proxy_auth_encrypted()?;
1922        to_save.refresh_provider_api_keys_encrypted()?;
1923        to_save.refresh_provider_instance_api_keys_encrypted()?;
1924        to_save.refresh_env_vars_encrypted()?;
1925        to_save.sanitize_env_vars_for_disk();
1926        to_save.normalize_tool_settings();
1927        to_save.normalize_skill_settings();
1928        let content =
1929            serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
1930        write_atomic(&path, content.as_bytes())
1931            .with_context(|| format!("Failed to write config file: {:?}", path))?;
1932
1933        Ok(())
1934    }
1935}
1936
1937fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
1938    let Some(parent) = path.parent() else {
1939        return std::fs::write(path, content);
1940    };
1941
1942    std::fs::create_dir_all(parent)?;
1943
1944    // Write to a temp file in the same directory then rename to ensure atomic replace.
1945    // (Rename is atomic on Unix when source/dest are on the same filesystem.)
1946    let file_name = path
1947        .file_name()
1948        .and_then(|s| s.to_str())
1949        .unwrap_or("config.json");
1950    let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
1951    let tmp_path = parent.join(tmp_name);
1952
1953    {
1954        let mut file = std::fs::File::create(&tmp_path)?;
1955        file.write_all(content)?;
1956        file.sync_all()?;
1957    }
1958
1959    std::fs::rename(&tmp_path, path)?;
1960    Ok(())
1961}
1962
1963#[cfg(test)]
1964mod tests {
1965    use super::*;
1966    use std::ffi::OsString;
1967    use std::path::PathBuf;
1968    use std::sync::{Mutex, OnceLock};
1969    use std::time::{SystemTime, UNIX_EPOCH};
1970
1971    struct EnvVarGuard {
1972        key: &'static str,
1973        previous: Option<OsString>,
1974    }
1975
1976    impl EnvVarGuard {
1977        fn set(key: &'static str, value: &str) -> Self {
1978            let previous = std::env::var_os(key);
1979            std::env::set_var(key, value);
1980            Self { key, previous }
1981        }
1982
1983        fn unset(key: &'static str) -> Self {
1984            let previous = std::env::var_os(key);
1985            std::env::remove_var(key);
1986            Self { key, previous }
1987        }
1988    }
1989
1990    impl Drop for EnvVarGuard {
1991        fn drop(&mut self) {
1992            match &self.previous {
1993                Some(value) => std::env::set_var(self.key, value),
1994                None => std::env::remove_var(self.key),
1995            }
1996        }
1997    }
1998
1999    struct TempHome {
2000        path: PathBuf,
2001    }
2002
2003    impl TempHome {
2004        fn new() -> Self {
2005            let nanos = SystemTime::now()
2006                .duration_since(UNIX_EPOCH)
2007                .expect("clock should be after unix epoch")
2008                .as_nanos();
2009            let path = std::env::temp_dir().join(format!(
2010                "chat-core-config-test-{}-{}",
2011                std::process::id(),
2012                nanos
2013            ));
2014            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
2015            Self { path }
2016        }
2017
2018        fn set_config_json(&self, content: &str) {
2019            // Treat `path` as the Bamboo data dir and write `config.json` into it.
2020            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
2021            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
2022            std::fs::write(self.path.join("config.json"), content)
2023                .expect("failed to write config.json");
2024        }
2025    }
2026
2027    impl Drop for TempHome {
2028        fn drop(&mut self) {
2029            let _ = std::fs::remove_dir_all(&self.path);
2030        }
2031    }
2032
2033    fn env_lock() -> &'static Mutex<()> {
2034        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2035        LOCK.get_or_init(|| Mutex::new(()))
2036    }
2037
2038    /// Acquire the environment lock, recovering from poison if a previous test failed
2039    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
2040        env_lock().lock().unwrap_or_else(|poisoned| {
2041            // Lock was poisoned by a previous test failure - recover it
2042            poisoned.into_inner()
2043        })
2044    }
2045
2046    #[test]
2047    fn parse_bool_env_true_values() {
2048        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
2049            assert!(parse_bool_env(value), "value {value:?} should be true");
2050        }
2051    }
2052
2053    #[test]
2054    fn parse_bool_env_false_values() {
2055        for value in ["0", "false", "no", "off", "", "  "] {
2056            assert!(!parse_bool_env(value), "value {value:?} should be false");
2057        }
2058    }
2059
2060    #[test]
2061    fn config_new_ignores_http_proxy_env_vars() {
2062        let _lock = env_lock_acquire();
2063        let temp_home = TempHome::new();
2064        temp_home.set_config_json(
2065            r#"{
2066  "http_proxy": "",
2067  "https_proxy": ""
2068}"#,
2069        );
2070
2071        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
2072        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
2073
2074        let config = Config::from_data_dir(Some(temp_home.path.clone()));
2075
2076        assert!(
2077            config.http_proxy.is_empty(),
2078            "config should ignore HTTP_PROXY env var"
2079        );
2080        assert!(
2081            config.https_proxy.is_empty(),
2082            "config should ignore HTTPS_PROXY env var"
2083        );
2084    }
2085
2086    #[test]
2087    fn config_new_loads_config_when_proxy_fields_omitted() {
2088        let _lock = env_lock_acquire();
2089        let temp_home = TempHome::new();
2090        temp_home.set_config_json(
2091            r#"{
2092  "provider": "openai",
2093  "providers": {
2094    "openai": {
2095      "api_key": "sk-test",
2096      "model": "gpt-4o"
2097    }
2098  }
2099}"#,
2100        );
2101
2102        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
2103        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
2104
2105        let config = Config::from_data_dir(Some(temp_home.path.clone()));
2106
2107        assert_eq!(
2108            config
2109                .providers
2110                .openai
2111                .as_ref()
2112                .and_then(|c| c.model.as_deref()),
2113            Some("gpt-4o"),
2114            "config should load provider model from config file even when proxy fields are omitted"
2115        );
2116        assert!(config.http_proxy.is_empty());
2117        assert!(config.https_proxy.is_empty());
2118    }
2119
2120    #[test]
2121    fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
2122        let _lock = crate::test_support::env_cache_lock_acquire();
2123        let mut config = Config::default();
2124        config.env_vars = vec![
2125            EnvVarEntry {
2126                name: "SECRET_TOKEN".to_string(),
2127                value: "top-secret".to_string(),
2128                secret: true,
2129                value_encrypted: None,
2130                description: Some("Service token".to_string()),
2131            },
2132            EnvVarEntry {
2133                name: "API_BASE".to_string(),
2134                value: "https://internal.example".to_string(),
2135                secret: false,
2136                value_encrypted: None,
2137                description: Some("Internal API base".to_string()),
2138            },
2139        ];
2140
2141        config.publish_env_vars();
2142
2143        let injected = Config::current_env_vars();
2144        assert_eq!(
2145            injected.get("SECRET_TOKEN").map(String::as_str),
2146            Some("top-secret")
2147        );
2148        assert_eq!(
2149            injected.get("API_BASE").map(String::as_str),
2150            Some("https://internal.example")
2151        );
2152
2153        let prompt_safe = Config::current_prompt_safe_env_vars();
2154        assert_eq!(prompt_safe.len(), 2);
2155        assert!(prompt_safe.iter().any(|entry| {
2156            entry.name == "SECRET_TOKEN"
2157                && entry.secret
2158                && entry.description.as_deref() == Some("Service token")
2159        }));
2160        assert!(prompt_safe.iter().any(|entry| {
2161            entry.name == "API_BASE"
2162                && !entry.secret
2163                && entry.description.as_deref() == Some("Internal API base")
2164        }));
2165        assert!(!prompt_safe
2166            .iter()
2167            .any(|entry| entry.name.contains("top-secret")));
2168        assert!(!prompt_safe.iter().any(|entry| {
2169            entry
2170                .description
2171                .as_deref()
2172                .is_some_and(|value| value.contains("https://internal.example"))
2173        }));
2174    }
2175
2176    #[test]
2177    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
2178        let _lock = env_lock_acquire();
2179        let temp_home = TempHome::new();
2180        temp_home.set_config_json(
2181            r#"{
2182  "provider": "openai",
2183  "providers": {
2184    "openai": {
2185      "api_key": "sk-test",
2186      "model": "gpt-4o"
2187    }
2188  }
2189}"#,
2190        );
2191
2192        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
2193        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
2194
2195        let config = Config::from_data_dir(Some(temp_home.path.clone()));
2196
2197        assert_eq!(
2198            config
2199                .providers
2200                .openai
2201                .as_ref()
2202                .and_then(|c| c.model.as_deref()),
2203            Some("gpt-4o")
2204        );
2205        assert!(
2206            config.http_proxy.is_empty(),
2207            "config should keep http_proxy empty when field is omitted"
2208        );
2209        assert!(
2210            config.https_proxy.is_empty(),
2211            "config should keep https_proxy empty when field is omitted"
2212        );
2213    }
2214
2215    #[test]
2216    fn get_memory_background_model_prefers_memory_specific_override() {
2217        let mut config = Config::default();
2218        config.features.provider_model_ref = false;
2219        config.provider = "openai".to_string();
2220        config.providers.openai = Some(OpenAIConfig {
2221            api_key: "test".to_string(),
2222            api_key_encrypted: None,
2223            base_url: None,
2224            model: Some("gpt-main".to_string()),
2225            fast_model: Some("gpt-fast".to_string()),
2226            vision_model: None,
2227            reasoning_effort: None,
2228            responses_only_models: vec![],
2229            request_overrides: None,
2230            extra: BTreeMap::new(),
2231        });
2232        config.memory = Some(MemoryConfig {
2233            background_model: Some("memory-fast".to_string()),
2234            ..MemoryConfig::default()
2235        });
2236
2237        assert_eq!(
2238            config.get_memory_background_model().as_deref(),
2239            Some("memory-fast")
2240        );
2241    }
2242
2243    #[test]
2244    fn get_memory_background_model_falls_back_to_provider_fast_model() {
2245        let mut config = Config::default();
2246        config.features.provider_model_ref = false;
2247        config.provider = "openai".to_string();
2248        config.providers.openai = Some(OpenAIConfig {
2249            api_key: "test".to_string(),
2250            api_key_encrypted: None,
2251            base_url: None,
2252            model: Some("gpt-main".to_string()),
2253            fast_model: Some("gpt-fast".to_string()),
2254            vision_model: None,
2255            reasoning_effort: None,
2256            responses_only_models: vec![],
2257            request_overrides: None,
2258            extra: BTreeMap::new(),
2259        });
2260
2261        assert_eq!(
2262            config.get_memory_background_model().as_deref(),
2263            Some("gpt-fast")
2264        );
2265    }
2266
2267    #[test]
2268    fn get_memory_background_model_does_not_fall_back_to_main_model() {
2269        let mut config = Config::default();
2270        config.features.provider_model_ref = false;
2271        config.provider = "openai".to_string();
2272        config.providers.openai = Some(OpenAIConfig {
2273            api_key: "test".to_string(),
2274            api_key_encrypted: None,
2275            base_url: None,
2276            model: Some("gpt-main".to_string()),
2277            fast_model: None,
2278            vision_model: None,
2279            reasoning_effort: None,
2280            responses_only_models: vec![],
2281            request_overrides: None,
2282            extra: BTreeMap::new(),
2283        });
2284
2285        assert!(config.get_memory_background_model().is_none());
2286    }
2287
2288    #[test]
2289    fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
2290        let config = Config {
2291            memory: Some(MemoryConfig {
2292                background_model: Some("dream-fast".to_string()),
2293                auto_dream_enabled: true,
2294                project_prompt_injection: false,
2295                relevant_recall: false,
2296                relevant_recall_rerank: true,
2297                project_first_dream: false,
2298                dream_refine_mode: true,
2299                ..MemoryConfig::default()
2300            }),
2301            ..Config::default()
2302        };
2303
2304        let serialized = serde_json::to_string(&config).expect("config should serialize");
2305        let roundtrip: Config =
2306            serde_json::from_str(&serialized).expect("config should deserialize");
2307        let memory = roundtrip.memory.expect("memory config should exist");
2308        assert!(memory.auto_dream_enabled);
2309        assert!(!memory.project_prompt_injection);
2310        assert!(!memory.relevant_recall);
2311        assert!(memory.relevant_recall_rerank);
2312        assert!(!memory.project_first_dream);
2313        assert!(memory.dream_refine_mode);
2314    }
2315
2316    #[test]
2317    fn memory_config_env_overrides_prompt_flags() {
2318        let _lock = env_lock_acquire();
2319        let temp_home = TempHome::new();
2320        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
2321        let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
2322        let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
2323        let _relevant_recall_rerank =
2324            EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
2325        let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
2326
2327        let config = Config::from_data_dir(Some(temp_home.path.clone()));
2328        let memory = config
2329            .memory
2330            .expect("memory config should be created by env overrides");
2331        assert!(!memory.project_prompt_injection);
2332        assert!(!memory.relevant_recall);
2333        assert!(memory.relevant_recall_rerank);
2334        assert!(!memory.project_first_dream);
2335    }
2336
2337    #[test]
2338    fn get_default_work_area_path_expands_tilde_and_requires_directory() {
2339        let _lock = env_lock_acquire();
2340        let temp_home = TempHome::new();
2341        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
2342        let target = temp_home.path.join("workspace-default");
2343        std::fs::create_dir_all(&target).expect("default work area dir should exist");
2344
2345        let mut config = Config::default();
2346        config.default_work_area = Some(DefaultWorkAreaConfig {
2347            path: Some("~/workspace-default".to_string()),
2348        });
2349
2350        assert_eq!(config.get_default_work_area_path(), Some(target));
2351    }
2352
2353    #[test]
2354    fn get_default_work_area_path_returns_none_for_missing_directory() {
2355        let _lock = env_lock_acquire();
2356        let temp_home = TempHome::new();
2357        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
2358
2359        let mut config = Config::default();
2360        config.default_work_area = Some(DefaultWorkAreaConfig {
2361            path: Some("~/missing-default-work-area".to_string()),
2362        });
2363
2364        assert!(config.get_default_work_area_path().is_none());
2365    }
2366
2367    #[test]
2368    fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
2369        let mut config = Config::default();
2370        config.tools.disabled = vec![
2371            "  read_file  ".to_string(),
2372            "".to_string(),
2373            "read_file".to_string(),
2374            "bash".to_string(),
2375            "default::getCurrentDir".to_string(),
2376        ];
2377
2378        config.normalize_tool_settings();
2379
2380        assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
2381    }
2382
2383    #[test]
2384    fn config_load_reads_disabled_tools_as_canonical_names() {
2385        let _lock = env_lock_acquire();
2386        let temp_home = TempHome::new();
2387        temp_home.set_config_json(
2388            r#"{
2389  "tools": {
2390    "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
2391  }
2392}"#,
2393        );
2394
2395        let config = Config::from_data_dir(Some(temp_home.path.clone()));
2396        assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
2397        assert!(config.disabled_tool_names().contains("Bash"));
2398        assert!(config.disabled_tool_names().contains("Read"));
2399        assert!(config.disabled_tool_names().contains("GetCurrentDir"));
2400    }
2401
2402    #[test]
2403    fn normalize_skill_settings_trims_dedupes_and_sorts() {
2404        let mut config = Config::default();
2405        config.skills.disabled = vec![
2406            " pdf ".to_string(),
2407            "".to_string(),
2408            "pdf".to_string(),
2409            "skill-creator".to_string(),
2410        ];
2411
2412        config.normalize_skill_settings();
2413
2414        assert_eq!(
2415            config.skills.disabled,
2416            vec!["pdf".to_string(), "skill-creator".to_string()]
2417        );
2418    }
2419
2420    #[test]
2421    fn config_load_reads_disabled_skills_as_normalized_ids() {
2422        let _lock = env_lock_acquire();
2423        let temp_home = TempHome::new();
2424        temp_home.set_config_json(
2425            r#"{
2426  "skills": {
2427    "disabled": [" pdf ", "skill-creator", "pdf", ""]
2428  }
2429}"#,
2430        );
2431
2432        let config = Config::from_data_dir(Some(temp_home.path.clone()));
2433        assert_eq!(
2434            config.skills.disabled,
2435            vec!["pdf".to_string(), "skill-creator".to_string()]
2436        );
2437        assert!(config.disabled_skill_ids().contains("pdf"));
2438        assert!(config.disabled_skill_ids().contains("skill-creator"));
2439    }
2440
2441    #[test]
2442    fn test_server_config_defaults() {
2443        let _lock = env_lock_acquire();
2444        let temp_home = TempHome::new();
2445
2446        let config = Config::from_data_dir(Some(temp_home.path.clone()));
2447        assert_eq!(config.server.port, 9562);
2448        assert_eq!(config.server.bind, "127.0.0.1");
2449        assert_eq!(config.server.workers, 10);
2450        assert!(config.server.static_dir.is_none());
2451    }
2452
2453    #[test]
2454    fn test_server_addr() {
2455        let mut config = Config::default();
2456        config.server.port = 9000;
2457        config.server.bind = "0.0.0.0".to_string();
2458        assert_eq!(config.server_addr(), "0.0.0.0:9000");
2459    }
2460
2461    #[test]
2462    fn test_env_var_overrides() {
2463        let _lock = env_lock_acquire();
2464        let temp_home = TempHome::new();
2465
2466        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
2467        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
2468        let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
2469
2470        let config = Config::from_data_dir(Some(temp_home.path.clone()));
2471        assert_eq!(config.server.port, 9999);
2472        assert_eq!(config.server.bind, "192.168.1.1");
2473        assert_eq!(config.provider, "openai");
2474    }
2475
2476    #[test]
2477    fn test_config_save_and_load() {
2478        let _lock = env_lock_acquire();
2479        let temp_home = TempHome::new();
2480
2481        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2482        config.server.port = 9000;
2483        config.server.bind = "0.0.0.0".to_string();
2484        config.provider = "anthropic".to_string();
2485
2486        // Save
2487        config
2488            .save_to_dir(temp_home.path.clone())
2489            .expect("Failed to save config");
2490
2491        // Load again
2492        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2493
2494        // Verify
2495        assert_eq!(loaded.server.port, 9000);
2496        assert_eq!(loaded.server.bind, "0.0.0.0");
2497        assert_eq!(loaded.provider, "anthropic");
2498    }
2499
2500    #[test]
2501    fn config_decrypts_proxy_auth_from_encrypted_field() {
2502        let _lock = env_lock_acquire();
2503        let temp_home = TempHome::new();
2504
2505        // Use a stable encryption key so this test doesn't depend on host identifiers.
2506        let key_guard = crate::encryption::set_test_encryption_key([
2507            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2508            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2509            0x1c, 0x1d, 0x1e, 0x1f,
2510        ]);
2511
2512        let auth = ProxyAuth {
2513            username: "user".to_string(),
2514            password: "pass".to_string(),
2515        };
2516        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
2517        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
2518
2519        temp_home.set_config_json(&format!(
2520            r#"{{
2521  "http_proxy": "http://proxy.example.com:8080",
2522  "proxy_auth_encrypted": "{encrypted}"
2523}}"#
2524        ));
2525        let config = Config::from_data_dir(Some(temp_home.path.clone()));
2526        let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
2527        assert_eq!(loaded_auth.username, "user");
2528        assert_eq!(loaded_auth.password, "pass");
2529        drop(key_guard);
2530    }
2531
2532    #[test]
2533    fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
2534        let _lock = env_lock_acquire();
2535        let temp_home = TempHome::new();
2536
2537        // Use a stable encryption key so this test doesn't depend on host identifiers.
2538        let key_guard = crate::encryption::set_test_encryption_key([
2539            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2540            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2541            0x1c, 0x1d, 0x1e, 0x1f,
2542        ]);
2543
2544        let auth = ProxyAuth {
2545            username: "user".to_string(),
2546            password: "pass".to_string(),
2547        };
2548        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
2549        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
2550
2551        // Simulate older Bodhi/Tauri persisted config keys.
2552        temp_home.set_config_json(&format!(
2553            r#"{{
2554  "http_proxy": "http://proxy.example.com:8080",
2555  "http_proxy_auth_encrypted": "{encrypted}",
2556  "https_proxy_auth_encrypted": "{encrypted}"
2557}}"#
2558        ));
2559
2560        let config = Config::from_data_dir(Some(temp_home.path.clone()));
2561        let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
2562        assert_eq!(loaded_auth.username, "user");
2563        assert_eq!(loaded_auth.password, "pass");
2564        drop(key_guard);
2565    }
2566
2567    #[test]
2568    fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
2569        let _lock = env_lock_acquire();
2570        let temp_home = TempHome::new();
2571
2572        // Use a stable encryption key so this test doesn't depend on host identifiers.
2573        let key_guard = crate::encryption::set_test_encryption_key([
2574            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2575            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2576            0x1c, 0x1d, 0x1e, 0x1f,
2577        ]);
2578
2579        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2580        config.proxy_auth = Some(ProxyAuth {
2581            username: "user".to_string(),
2582            password: "pass".to_string(),
2583        });
2584        config
2585            .save_to_dir(temp_home.path.clone())
2586            .expect("save should encrypt proxy auth");
2587
2588        let content =
2589            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
2590        assert!(
2591            content.contains("proxy_auth_encrypted"),
2592            "config.json should store encrypted proxy auth"
2593        );
2594        assert!(
2595            !content.contains("\"proxy_auth\""),
2596            "config.json should not store plaintext proxy_auth"
2597        );
2598
2599        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2600        let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
2601        assert_eq!(loaded_auth.username, "user");
2602        assert_eq!(loaded_auth.password, "pass");
2603        drop(key_guard);
2604    }
2605
2606    #[test]
2607    fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
2608        let _lock = env_lock_acquire();
2609        let temp_home = TempHome::new();
2610
2611        // Use a stable encryption key so this test doesn't depend on host identifiers.
2612        let key_guard = crate::encryption::set_test_encryption_key([
2613            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
2614            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
2615            0x1c, 0x1d, 0x1e, 0x1f,
2616        ]);
2617
2618        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2619        config.provider = "openai".to_string();
2620        config.providers.openai = Some(OpenAIConfig {
2621            api_key: "sk-test-provider-key".to_string(),
2622            api_key_encrypted: None,
2623            base_url: None,
2624            model: None,
2625            fast_model: None,
2626            vision_model: None,
2627            reasoning_effort: None,
2628            responses_only_models: vec![],
2629            request_overrides: None,
2630            extra: Default::default(),
2631        });
2632
2633        config
2634            .save_to_dir(temp_home.path.clone())
2635            .expect("save should encrypt provider api keys");
2636
2637        let content =
2638            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
2639        assert!(
2640            content.contains("\"api_key_encrypted\""),
2641            "config.json should store encrypted provider keys"
2642        );
2643        assert!(
2644            !content.contains("\"api_key\""),
2645            "config.json should not store plaintext provider keys"
2646        );
2647
2648        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2649        let openai = loaded
2650            .providers
2651            .openai
2652            .expect("openai config should be present");
2653        assert_eq!(openai.api_key, "sk-test-provider-key");
2654
2655        drop(key_guard);
2656    }
2657
2658    #[test]
2659    fn config_save_persists_mcp_servers_in_mainstream_format() {
2660        let _lock = env_lock_acquire();
2661        let temp_home = TempHome::new();
2662
2663        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
2664
2665        let mut env = std::collections::HashMap::new();
2666        env.insert("TOKEN".to_string(), "supersecret".to_string());
2667
2668        config.mcp.servers = vec![
2669            bamboo_domain::mcp_config::McpServerConfig {
2670                id: "stdio-secret".to_string(),
2671                name: None,
2672                enabled: true,
2673                transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
2674                    bamboo_domain::mcp_config::StdioConfig {
2675                        command: "echo".to_string(),
2676                        args: vec![],
2677                        cwd: None,
2678                        env,
2679                        env_encrypted: std::collections::HashMap::new(),
2680                        startup_timeout_ms: 5000,
2681                    },
2682                ),
2683                request_timeout_ms: 5000,
2684                healthcheck_interval_ms: 1000,
2685                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
2686                allowed_tools: vec![],
2687                denied_tools: vec![],
2688            },
2689            bamboo_domain::mcp_config::McpServerConfig {
2690                id: "sse-secret".to_string(),
2691                name: None,
2692                enabled: true,
2693                transport: bamboo_domain::mcp_config::TransportConfig::Sse(
2694                    bamboo_domain::mcp_config::SseConfig {
2695                        url: "http://localhost:8080/sse".to_string(),
2696                        headers: vec![bamboo_domain::mcp_config::HeaderConfig {
2697                            name: "Authorization".to_string(),
2698                            value: "Bearer token123".to_string(),
2699                            value_encrypted: None,
2700                        }],
2701                        connect_timeout_ms: 5000,
2702                    },
2703                ),
2704                request_timeout_ms: 5000,
2705                healthcheck_interval_ms: 1000,
2706                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
2707                allowed_tools: vec![],
2708                denied_tools: vec![],
2709            },
2710        ];
2711
2712        config
2713            .save_to_dir(temp_home.path.clone())
2714            .expect("save should persist MCP servers");
2715
2716        let content =
2717            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
2718        assert!(
2719            content.contains("\"mcpServers\""),
2720            "config.json should store MCP servers under the mainstream 'mcpServers' key"
2721        );
2722        assert!(
2723            content.contains("supersecret"),
2724            "config.json should persist MCP stdio env in mainstream format"
2725        );
2726        assert!(
2727            content.contains("Bearer token123"),
2728            "config.json should persist MCP SSE headers in mainstream format"
2729        );
2730        assert!(
2731            !content.contains("\"env_encrypted\""),
2732            "config.json should not persist legacy env_encrypted fields"
2733        );
2734        assert!(
2735            !content.contains("\"value_encrypted\""),
2736            "config.json should not persist legacy value_encrypted fields"
2737        );
2738
2739        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
2740        let stdio = loaded
2741            .mcp
2742            .servers
2743            .iter()
2744            .find(|s| s.id == "stdio-secret")
2745            .expect("stdio server should exist");
2746        match &stdio.transport {
2747            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
2748                assert_eq!(
2749                    stdio.env.get("TOKEN").map(|s| s.as_str()),
2750                    Some("supersecret")
2751                );
2752            }
2753            _ => panic!("Expected stdio transport"),
2754        }
2755
2756        let sse = loaded
2757            .mcp
2758            .servers
2759            .iter()
2760            .find(|s| s.id == "sse-secret")
2761            .expect("sse server should exist");
2762        match &sse.transport {
2763            bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
2764                assert_eq!(sse.headers[0].value, "Bearer token123");
2765            }
2766            _ => panic!("Expected SSE transport"),
2767        }
2768    }
2769
2770    // ── Env vars lifecycle tests ──────────────────────────────
2771
2772    #[test]
2773    fn env_vars_as_map_includes_only_non_empty_values() {
2774        let mut config = Config::default();
2775        config.env_vars = vec![
2776            EnvVarEntry {
2777                name: "A".to_string(),
2778                value: "val_a".to_string(),
2779                secret: false,
2780                value_encrypted: None,
2781                description: None,
2782            },
2783            EnvVarEntry {
2784                name: "B".to_string(),
2785                value: "".to_string(), // empty → should be excluded
2786                secret: true,
2787                value_encrypted: None,
2788                description: None,
2789            },
2790            EnvVarEntry {
2791                name: "C".to_string(),
2792                value: "  ".to_string(), // whitespace-only → excluded
2793                secret: false,
2794                value_encrypted: None,
2795                description: None,
2796            },
2797            EnvVarEntry {
2798                name: "D".to_string(),
2799                value: "val_d".to_string(),
2800                secret: true,
2801                value_encrypted: Some("enc".to_string()),
2802                description: Some("desc".to_string()),
2803            },
2804        ];
2805
2806        let map = config.env_vars_as_map();
2807        assert_eq!(map.len(), 2);
2808        assert_eq!(map.get("A"), Some(&"val_a".to_string()));
2809        assert_eq!(map.get("D"), Some(&"val_d".to_string()));
2810        assert!(!map.contains_key("B"));
2811        assert!(!map.contains_key("C"));
2812    }
2813
2814    #[test]
2815    fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
2816        let mut config = Config::default();
2817        config.env_vars = vec![
2818            EnvVarEntry {
2819                name: "PLAIN".to_string(),
2820                value: "visible".to_string(),
2821                secret: false,
2822                value_encrypted: None,
2823                description: None,
2824            },
2825            EnvVarEntry {
2826                name: "SECRET".to_string(),
2827                value: "hidden_value".to_string(),
2828                secret: true,
2829                value_encrypted: Some("enc_data".to_string()),
2830                description: None,
2831            },
2832        ];
2833
2834        config.sanitize_env_vars_for_disk();
2835
2836        assert_eq!(config.env_vars[0].value, "visible"); // plain kept
2837        assert_eq!(config.env_vars[1].value, ""); // secret cleared
2838    }
2839
2840    #[test]
2841    fn sanitize_env_vars_for_disk_preserves_encrypted() {
2842        let mut config = Config::default();
2843        config.env_vars = vec![
2844            EnvVarEntry {
2845                name: "OPEN".to_string(),
2846                value: "val".to_string(),
2847                secret: false,
2848                value_encrypted: None,
2849                description: None,
2850            },
2851            EnvVarEntry {
2852                name: "HIDDEN".to_string(),
2853                value: "real_secret".to_string(),
2854                secret: true,
2855                value_encrypted: Some("enc".to_string()),
2856                description: None,
2857            },
2858        ];
2859
2860        config.sanitize_env_vars_for_disk();
2861
2862        // Plain value untouched
2863        assert_eq!(config.env_vars[0].value, "val");
2864        // Secret plaintext cleared, but encrypted preserved
2865        assert_eq!(config.env_vars[1].value, "");
2866        assert_eq!(config.env_vars[1].value_encrypted.as_deref(), Some("enc"));
2867    }
2868
2869    #[test]
2870    fn refresh_env_vars_encrypted_round_trip() {
2871        let mut config = Config::default();
2872        config.env_vars = vec![
2873            EnvVarEntry {
2874                name: "TOKEN".to_string(),
2875                value: "my-secret-token".to_string(),
2876                secret: true,
2877                value_encrypted: None,
2878                description: Some("A token".to_string()),
2879            },
2880            EnvVarEntry {
2881                name: "PLAIN_VAR".to_string(),
2882                value: "hello".to_string(),
2883                secret: false,
2884                value_encrypted: None,
2885                description: None,
2886            },
2887        ];
2888
2889        // Encrypt
2890        config
2891            .refresh_env_vars_encrypted()
2892            .expect("encryption should succeed");
2893
2894        // Secret should now have encrypted value
2895        assert!(config.env_vars[0].value_encrypted.is_some());
2896        // Plain should have no encrypted value
2897        assert!(config.env_vars[1].value_encrypted.is_none());
2898
2899        // Save encrypted value for later comparison
2900        let encrypted = config.env_vars[0].value_encrypted.clone().unwrap();
2901        assert_ne!(encrypted, "my-secret-token"); // shouldn't be plaintext
2902
2903        // Clear plaintext (simulating disk write)
2904        config.sanitize_env_vars_for_disk();
2905        assert_eq!(config.env_vars[0].value, "");
2906
2907        // Hydrate (simulating disk read)
2908        config.hydrate_env_vars_from_encrypted();
2909        assert_eq!(config.env_vars[0].value, "my-secret-token");
2910        assert_eq!(config.env_vars[1].value, "hello"); // plain untouched
2911    }
2912
2913    #[test]
2914    fn publish_and_current_env_vars_round_trip() {
2915        let mut config = Config::default();
2916        config.env_vars = vec![EnvVarEntry {
2917            name: "TEST_PUBLISH".to_string(),
2918            value: "pub_value".to_string(),
2919            secret: false,
2920            value_encrypted: None,
2921            description: None,
2922        }];
2923
2924        for _ in 0..10 {
2925            config.publish_env_vars();
2926            let map = Config::current_env_vars();
2927            if map.get("TEST_PUBLISH") == Some(&"pub_value".to_string()) {
2928                return;
2929            }
2930        }
2931        panic!("TEST_PUBLISH not found in cache after retries");
2932    }
2933
2934    #[test]
2935    fn hydrate_skips_non_secret_entries() {
2936        let mut config = Config::default();
2937        config.env_vars = vec![EnvVarEntry {
2938            name: "PLAIN".to_string(),
2939            value: "original".to_string(),
2940            secret: false,
2941            value_encrypted: Some("should-be-ignored".to_string()),
2942            description: None,
2943        }];
2944
2945        config.hydrate_env_vars_from_encrypted();
2946        // Non-secret entry should keep its original value
2947        assert_eq!(config.env_vars[0].value, "original");
2948    }
2949
2950    #[test]
2951    fn default_config_has_empty_env_vars() {
2952        let config = Config::default();
2953        assert!(config.env_vars.is_empty());
2954    }
2955
2956    #[test]
2957    fn serde_round_trip_with_env_vars() {
2958        let mut config = Config::default();
2959        config.env_vars = vec![
2960            EnvVarEntry {
2961                name: "KEY1".to_string(),
2962                value: "val1".to_string(),
2963                secret: false,
2964                value_encrypted: None,
2965                description: Some("First key".to_string()),
2966            },
2967            EnvVarEntry {
2968                name: "KEY2".to_string(),
2969                value: "".to_string(), // on-disk secret has no plaintext
2970                secret: true,
2971                value_encrypted: Some("enc123".to_string()),
2972                description: None,
2973            },
2974        ];
2975
2976        let json = serde_json::to_string(&config).unwrap();
2977        let restored: Config = serde_json::from_str(&json).unwrap();
2978
2979        assert_eq!(restored.env_vars.len(), 2);
2980        assert_eq!(restored.env_vars[0].name, "KEY1");
2981        assert_eq!(restored.env_vars[0].value, "val1");
2982        assert!(!restored.env_vars[0].secret);
2983        assert_eq!(restored.env_vars[1].name, "KEY2");
2984        assert!(restored.env_vars[1].secret);
2985        assert_eq!(
2986            restored.env_vars[1].value_encrypted.as_deref(),
2987            Some("enc123")
2988        );
2989    }
2990
2991    // ---- defaults.* model resolution tests ----
2992
2993    #[test]
2994    fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
2995        let mut config = Config::default();
2996        config.provider = "openai".to_string();
2997        config.providers.openai = Some(OpenAIConfig {
2998            api_key: "test".to_string(),
2999            api_key_encrypted: None,
3000            base_url: None,
3001            model: Some("legacy-gpt-4o".to_string()),
3002            fast_model: None,
3003            vision_model: None,
3004            reasoning_effort: None,
3005            responses_only_models: vec![],
3006            request_overrides: None,
3007            extra: Default::default(),
3008        });
3009        config.features.provider_model_ref = true;
3010        config.defaults = Some(DefaultsConfig {
3011            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
3012            fast: None,
3013            vision: None,
3014            memory_background: None,
3015            planning: None,
3016            search: None,
3017            code_review: None,
3018            sub_agent: None,
3019            subagent_models: Default::default(),
3020        });
3021
3022        assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
3023    }
3024
3025    #[test]
3026    fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
3027        let mut config = Config::default();
3028        config.provider = "openai".to_string();
3029        config.providers.openai = Some(OpenAIConfig {
3030            api_key: "test".to_string(),
3031            api_key_encrypted: None,
3032            base_url: None,
3033            model: Some("legacy-gpt-4o".to_string()),
3034            fast_model: None,
3035            vision_model: None,
3036            reasoning_effort: None,
3037            responses_only_models: vec![],
3038            request_overrides: None,
3039            extra: Default::default(),
3040        });
3041        config.features.provider_model_ref = false;
3042        config.defaults = Some(DefaultsConfig {
3043            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
3044            fast: None,
3045            vision: None,
3046            memory_background: None,
3047            planning: None,
3048            search: None,
3049            code_review: None,
3050            sub_agent: None,
3051            subagent_models: Default::default(),
3052        });
3053
3054        assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
3055    }
3056
3057    #[test]
3058    fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
3059        let mut config = Config::default();
3060        config.provider = "openai".to_string();
3061        config.providers.openai = Some(OpenAIConfig {
3062            api_key: "test".to_string(),
3063            api_key_encrypted: None,
3064            base_url: None,
3065            model: Some("gpt-4o".to_string()),
3066            fast_model: Some("legacy-gpt-4o-mini".to_string()),
3067            vision_model: None,
3068            reasoning_effort: None,
3069            responses_only_models: vec![],
3070            request_overrides: None,
3071            extra: Default::default(),
3072        });
3073        config.features.provider_model_ref = true;
3074        config.defaults = Some(DefaultsConfig {
3075            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3076            fast: Some(bamboo_domain::ProviderModelRef::new(
3077                "anthropic",
3078                "claude-3-5-haiku",
3079            )),
3080            vision: None,
3081            memory_background: None,
3082            planning: None,
3083            search: None,
3084            code_review: None,
3085            sub_agent: None,
3086            subagent_models: Default::default(),
3087        });
3088
3089        assert_eq!(
3090            config.get_fast_model(),
3091            Some("claude-3-5-haiku".to_string())
3092        );
3093    }
3094
3095    #[test]
3096    fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
3097        let mut config = Config::default();
3098        config.provider = "openai".to_string();
3099        config.providers.openai = Some(OpenAIConfig {
3100            api_key: "test".to_string(),
3101            api_key_encrypted: None,
3102            base_url: None,
3103            model: Some("gpt-4o".to_string()),
3104            fast_model: Some("legacy-gpt-4o-mini".to_string()),
3105            vision_model: None,
3106            reasoning_effort: None,
3107            responses_only_models: vec![],
3108            request_overrides: None,
3109            extra: Default::default(),
3110        });
3111        config.features.provider_model_ref = false;
3112        config.defaults = Some(DefaultsConfig {
3113            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3114            fast: Some(bamboo_domain::ProviderModelRef::new(
3115                "anthropic",
3116                "claude-3-5-haiku",
3117            )),
3118            vision: None,
3119            memory_background: None,
3120            planning: None,
3121            search: None,
3122            code_review: None,
3123            sub_agent: None,
3124            subagent_models: Default::default(),
3125        });
3126
3127        assert_eq!(
3128            config.get_fast_model(),
3129            Some("legacy-gpt-4o-mini".to_string())
3130        );
3131    }
3132
3133    #[test]
3134    fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
3135        let mut config = Config::default();
3136        config.provider = "openai".to_string();
3137        config.features.provider_model_ref = true;
3138        config.defaults = Some(DefaultsConfig {
3139            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
3140            fast: None,
3141            vision: None,
3142            memory_background: None,
3143            planning: None,
3144            search: None,
3145            code_review: None,
3146            sub_agent: None,
3147            subagent_models: Default::default(),
3148        });
3149
3150        assert_eq!(
3151            config.get_fast_model(),
3152            Some("claude-3-7-sonnet".to_string())
3153        );
3154    }
3155
3156    #[test]
3157    fn get_memory_background_model_prefers_defaults_memory_background() {
3158        let mut config = Config::default();
3159        config.provider = "openai".to_string();
3160        config.providers.openai = Some(OpenAIConfig {
3161            api_key: "test".to_string(),
3162            api_key_encrypted: None,
3163            base_url: None,
3164            model: Some("gpt-4o".to_string()),
3165            fast_model: Some("gpt-4o-mini".to_string()),
3166            vision_model: None,
3167            reasoning_effort: None,
3168            responses_only_models: vec![],
3169            request_overrides: None,
3170            extra: Default::default(),
3171        });
3172        config.features.provider_model_ref = true;
3173        config.defaults = Some(DefaultsConfig {
3174            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3175            fast: Some(bamboo_domain::ProviderModelRef::new(
3176                "openai",
3177                "gpt-4o-mini",
3178            )),
3179            vision: None,
3180            memory_background: Some(bamboo_domain::ProviderModelRef::new(
3181                "anthropic",
3182                "claude-3-5-haiku",
3183            )),
3184            planning: None,
3185            search: None,
3186            code_review: None,
3187            sub_agent: None,
3188            subagent_models: Default::default(),
3189        });
3190
3191        assert_eq!(
3192            config.get_memory_background_model(),
3193            Some("claude-3-5-haiku".to_string())
3194        );
3195    }
3196
3197    #[test]
3198    fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
3199        let mut config = Config::default();
3200        config.provider = "openai".to_string();
3201        config.features.provider_model_ref = true;
3202        config.defaults = Some(DefaultsConfig {
3203            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3204            fast: Some(bamboo_domain::ProviderModelRef::new(
3205                "anthropic",
3206                "claude-3-5-haiku",
3207            )),
3208            vision: None,
3209            memory_background: None,
3210            planning: None,
3211            search: None,
3212            code_review: None,
3213            sub_agent: None,
3214            subagent_models: Default::default(),
3215        });
3216
3217        assert_eq!(
3218            config.get_memory_background_model(),
3219            Some("claude-3-5-haiku".to_string())
3220        );
3221    }
3222
3223    #[test]
3224    fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
3225        let mut config = Config::default();
3226        config.provider = "openai".to_string();
3227        config.providers.openai = Some(OpenAIConfig {
3228            api_key: "test".to_string(),
3229            api_key_encrypted: None,
3230            base_url: None,
3231            model: Some("gpt-4o".to_string()),
3232            fast_model: Some("legacy-gpt-4o-mini".to_string()),
3233            vision_model: None,
3234            reasoning_effort: None,
3235            responses_only_models: vec![],
3236            request_overrides: None,
3237            extra: Default::default(),
3238        });
3239        config.features.provider_model_ref = false;
3240        config.defaults = Some(DefaultsConfig {
3241            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
3242            fast: Some(bamboo_domain::ProviderModelRef::new(
3243                "anthropic",
3244                "claude-3-5-haiku",
3245            )),
3246            vision: None,
3247            memory_background: Some(bamboo_domain::ProviderModelRef::new(
3248                "anthropic",
3249                "claude-3-5-haiku",
3250            )),
3251            planning: None,
3252            search: None,
3253            code_review: None,
3254            sub_agent: None,
3255            subagent_models: Default::default(),
3256        });
3257
3258        assert_eq!(
3259            config.get_memory_background_model(),
3260            Some("legacy-gpt-4o-mini".to_string())
3261        );
3262    }
3263}