Skip to main content

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