use anyhow::{Context, Result};
use bamboo_domain::poison::PoisonRecover;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use crate::keyword_masking::KeywordMaskingConfig;
use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
use bamboo_domain::tool_names::normalize_tool_ref;
use bamboo_domain::ReasoningEffort;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnvVarEntry {
pub name: String,
#[serde(default)]
pub value: String,
#[serde(default)]
pub secret: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_encrypted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DefaultWorkAreaConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct AccessControlConfig {
#[serde(default)]
pub password_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_salt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub devices: Vec<DeviceCredential>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeviceCredential {
pub device_id: String,
pub label: String,
pub token_hash: String,
pub token_salt: String,
pub created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_used_at: Option<String>,
#[serde(default)]
pub revoked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_model: Option<String>,
#[serde(default = "default_true_auto_dream_enabled")]
pub auto_dream_enabled: bool,
#[serde(default = "default_auto_dream_interval_secs")]
pub auto_dream_interval_secs: u64,
#[serde(
default = "default_true_memory_project_prompt_injection",
alias = "memory_project_prompt_injection"
)]
pub project_prompt_injection: bool,
#[serde(
default = "default_true_memory_relevant_recall",
alias = "memory_relevant_recall"
)]
pub relevant_recall: bool,
#[serde(default, alias = "memory_relevant_recall_rerank")]
pub relevant_recall_rerank: bool,
#[serde(
default = "default_true_memory_project_first_dream",
alias = "memory_project_first_dream"
)]
pub project_first_dream: bool,
#[serde(default, alias = "memory_dream_refine_mode")]
pub dream_refine_mode: bool,
#[serde(
default = "default_true_gardener_enabled",
alias = "memory_gardener_enabled"
)]
pub gardener_enabled: bool,
#[serde(default = "default_gardener_interval_secs")]
pub gardener_interval_secs: u64,
#[serde(default = "default_gardener_volume_trigger")]
pub gardener_volume_trigger: usize,
#[serde(default = "default_gardener_max_splits_per_run")]
pub gardener_max_splits_per_run: usize,
#[serde(default = "default_gardener_min_sections")]
pub gardener_min_sections: usize,
#[serde(
default = "default_true_dedup_gardener_enabled",
alias = "memory_dedup_gardener_enabled"
)]
pub dedup_gardener_enabled: bool,
#[serde(default = "default_dedup_gardener_min_score")]
pub dedup_gardener_min_score: f64,
#[serde(default = "default_dedup_gardener_max_merges_per_run")]
pub dedup_gardener_max_merges_per_run: usize,
#[serde(default)]
pub memory_active_capacity: usize,
#[serde(default = "default_capacity_max_archivals_per_run")]
pub capacity_max_archivals_per_run: usize,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
background_model: None,
auto_dream_enabled: default_true_auto_dream_enabled(),
auto_dream_interval_secs: default_auto_dream_interval_secs(),
project_prompt_injection: default_true_memory_project_prompt_injection(),
relevant_recall: default_true_memory_relevant_recall(),
relevant_recall_rerank: false,
project_first_dream: default_true_memory_project_first_dream(),
dream_refine_mode: false,
gardener_enabled: default_true_gardener_enabled(),
gardener_interval_secs: default_gardener_interval_secs(),
gardener_volume_trigger: default_gardener_volume_trigger(),
gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
gardener_min_sections: default_gardener_min_sections(),
dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
dedup_gardener_min_score: default_dedup_gardener_min_score(),
dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
memory_active_capacity: 0,
capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
}
}
}
fn default_capacity_max_archivals_per_run() -> usize {
50
}
fn default_true_auto_dream_enabled() -> bool {
true
}
fn default_true_gardener_enabled() -> bool {
true
}
fn default_true_dedup_gardener_enabled() -> bool {
true
}
fn default_gardener_volume_trigger() -> usize {
25
}
fn default_gardener_interval_secs() -> u64 {
86_400
}
fn default_auto_dream_interval_secs() -> u64 {
60 * 30
}
fn default_gardener_max_splits_per_run() -> usize {
8
}
fn default_gardener_min_sections() -> usize {
5
}
fn default_dedup_gardener_min_score() -> f64 {
0.6
}
fn default_dedup_gardener_max_merges_per_run() -> usize {
8
}
fn default_true_memory_project_prompt_injection() -> bool {
true
}
fn default_true_memory_relevant_recall() -> bool {
true
}
fn default_true_memory_project_first_dream() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SubagentsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_bin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_args: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fabric_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub executor: Option<String>,
#[serde(skip)]
pub broker: Option<BrokerClientConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub remote_placements: Vec<RemoteActorPlacement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub schedulable_placements: Vec<SchedulablePlacement>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SchedulablePlacement {
pub role: String,
pub pool: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub registry_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_cert_file: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RemoteActorPlacement {
pub role: String,
pub endpoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_cert_file: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct BrokerClientConfig {
pub endpoint: String,
#[serde(default)]
pub token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_encrypted: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub http_proxy: String,
#[serde(default)]
pub https_proxy: String,
#[serde(skip_serializing)]
pub proxy_auth: Option<ProxyAuth>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy_auth_encrypted: Option<String>,
#[serde(default)]
pub headless_auth: bool,
#[serde(default = "default_provider")]
pub provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defaults: Option<DefaultsConfig>,
#[serde(default)]
pub providers: ProviderConfigs,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub provider_instances: HashMap<String, ProviderInstanceConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_provider_instance: Option<String>,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub keyword_masking: KeywordMaskingConfig,
#[serde(default)]
pub anthropic_model_mapping: AnthropicModelMapping,
#[serde(default)]
pub gemini_model_mapping: GeminiModelMapping,
#[serde(default)]
pub hooks: HooksConfig,
#[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
pub tools: ToolsConfig,
#[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
pub skills: SkillsConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_vars: Vec<EnvVarEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_work_area: Option<DefaultWorkAreaConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_control: Option<AccessControlConfig>,
#[serde(default)]
pub features: FeatureFlags,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<MemoryConfig>,
#[serde(default)]
pub subagents: SubagentsConfig,
#[serde(
default,
skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
)]
pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
#[serde(default, rename = "mcpServers", alias = "mcp")]
pub mcp: bamboo_domain::mcp_config::McpConfig,
#[serde(default, flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProviderConfigs {
#[serde(skip_serializing_if = "Option::is_none")]
pub openai: Option<OpenAIConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anthropic: Option<AnthropicConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gemini: Option<GeminiConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub copilot: Option<CopilotConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bodhi: Option<BodhiConfig>,
#[serde(default, flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FeatureFlags {
#[serde(default)]
pub provider_model_ref: bool,
#[serde(default)]
pub dynamic_model_routing: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DefaultsConfig {
pub chat: bamboo_domain::ProviderModelRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fast: Option<bamboo_domain::ProviderModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_summary: Option<bamboo_domain::ProviderModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vision: Option<bamboo_domain::ProviderModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_background: Option<bamboo_domain::ProviderModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub planning: Option<bamboo_domain::ProviderModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search: Option<bamboo_domain::ProviderModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code_review: Option<bamboo_domain::ProviderModelRef>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "sub_session"
)]
pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HooksConfig {
#[serde(default)]
pub image_fallback: ImageFallbackHookConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct RequestOverridesConfig {
#[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
pub common: RequestScopeOverride,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub endpoints: BTreeMap<String, RequestScopeOverride>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<ModelRequestRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelRequestRule {
pub model_pattern: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
pub scope: RequestScopeOverride,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct RequestScopeOverride {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, TemplateExpr>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub body_patch: Vec<BodyPatch>,
}
impl RequestScopeOverride {
pub fn is_empty(&self) -> bool {
self.headers.is_empty() && self.body_patch.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BodyPatch {
pub path: String,
#[serde(default)]
pub op: BodyPatchOp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<PatchValue>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BodyPatchOp {
#[default]
Set,
Remove,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum PatchValue {
Template(TemplateExpr),
Json(Value),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum TemplateExpr {
Literal(String),
Structured(TemplateExprSpec),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TemplateExprSpec {
Literal { value: String },
EnvRef {
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
fallback: Option<String>,
},
Generated { generator: GeneratedValue },
Format { template: String },
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GeneratedValue {
Uuid,
UnixMs,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolsConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
}
impl ToolsConfig {
fn is_empty(&self) -> bool {
self.disabled.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SkillsConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
}
impl SkillsConfig {
fn is_empty(&self) -> bool {
self.disabled.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageFallbackHookConfig {
#[serde(default = "default_true_hooks")]
pub enabled: bool,
#[serde(default = "default_image_fallback_mode")]
pub mode: String,
}
impl Default for ImageFallbackHookConfig {
fn default() -> Self {
Self {
enabled: default_true_hooks(),
mode: default_image_fallback_mode(),
}
}
}
fn default_image_fallback_mode() -> String {
"placeholder".to_string()
}
fn default_true_hooks() -> bool {
false
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpenAIConfig {
#[serde(default, skip_serializing)]
pub api_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_encrypted: Option<String>,
#[serde(skip)]
pub api_key_from_env: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fast_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vision_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub responses_only_models: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_overrides: Option<RequestOverridesConfig>,
#[serde(default, flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AnthropicConfig {
#[serde(default, skip_serializing)]
pub api_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_encrypted: Option<String>,
#[serde(skip)]
pub api_key_from_env: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fast_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vision_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_overrides: Option<RequestOverridesConfig>,
#[serde(default, flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GeminiConfig {
#[serde(default, skip_serializing)]
pub api_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_encrypted: Option<String>,
#[serde(skip)]
pub api_key_from_env: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fast_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vision_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_overrides: Option<RequestOverridesConfig>,
#[serde(default, flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CopilotConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub headless_auth: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fast_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vision_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub responses_only_models: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_overrides: Option<RequestOverridesConfig>,
#[serde(default, flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BodhiConfig {
#[serde(default, skip_serializing)]
pub api_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_encrypted: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, flatten)]
pub extra: BTreeMap<String, Value>,
}
fn default_provider() -> String {
"anthropic".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProviderInstanceConfig {
pub provider_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing)]
pub api_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_encrypted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fast_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vision_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub responses_only_models: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_overrides: Option<RequestOverridesConfig>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, flatten)]
pub extra: BTreeMap<String, Value>,
}
fn default_true() -> bool {
true
}
fn default_port() -> u16 {
9562
}
fn default_bind() -> String {
"127.0.0.1".to_string()
}
fn default_workers() -> usize {
10
}
fn default_data_dir() -> PathBuf {
super::paths::bamboo_dir()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_bind")]
pub bind: String,
pub static_dir: Option<PathBuf>,
#[serde(default = "default_workers")]
pub workers: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls: Option<TlsConfig>,
#[serde(default, flatten)]
pub extra: BTreeMap<String, Value>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
port: default_port(),
bind: default_bind(),
static_dir: None,
workers: default_workers(),
tls: None,
extra: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TlsConfig {
pub cert_file: PathBuf,
pub key_file: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyAuth {
pub username: String,
pub password: String,
}
fn parse_bool_env(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "y" | "on"
)
}
fn expand_user_path(value: &str) -> PathBuf {
let trimmed = value.trim();
if let Some(rest) = trimmed.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
PathBuf::from(trimmed)
}
impl Default for Config {
fn default() -> Self {
Self::create_default()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptSafeEnvVarEntry {
pub name: String,
pub secret: bool,
pub description: Option<String>,
}
static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}
fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
}
impl Config {
pub fn new() -> Self {
Self::from_data_dir(None)
}
fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
let data_dir = data_dir
.or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
.unwrap_or_else(default_data_dir);
let config_path = data_dir.join("config.json");
let mut config = if config_path.exists() {
if let Ok(content) = std::fs::read_to_string(&config_path) {
Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
tracing::warn!(
"Failed to parse config.json ({}); quarantining it and attempting recovery",
e
);
quarantine_corrupt_config(&config_path);
Self::salvage_partial(&content, &data_dir)
.or_else(|| Self::load_backup(&data_dir))
.unwrap_or_else(|| {
tracing::warn!(
"Could not salvage and no usable config.json.bak; using defaults"
);
Self::create_default()
})
})
} else {
Self::create_default()
}
} else {
Self::create_default()
};
config.hydrate_proxy_auth_from_encrypted();
config.hydrate_provider_api_keys_from_encrypted();
config.hydrate_provider_instance_api_keys_from_encrypted();
config.hydrate_mcp_secrets_from_encrypted();
config.hydrate_env_vars_from_encrypted();
config.hydrate_cluster_fabric_from_encrypted();
config.hydrate_broker_token_from_encrypted();
config.normalize_tool_settings();
config.normalize_skill_settings();
config.extra.remove("data_dir");
if apply_env {
config.apply_env_overrides();
}
if publish {
config.publish_env_vars();
}
config
}
fn apply_env_overrides(&mut self) {
if let Ok(port) = std::env::var("BAMBOO_PORT") {
if let Ok(port) = port.parse() {
self.server.port = port;
}
}
if let Ok(bind) = std::env::var("BAMBOO_BIND") {
self.server.bind = bind;
}
if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
self.provider = provider;
}
if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
self.headless_auth = parse_bool_env(&headless);
}
if let Ok(project_prompt_injection) =
std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
{
let memory = self.memory.get_or_insert_with(MemoryConfig::default);
memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
}
if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
let memory = self.memory.get_or_insert_with(MemoryConfig::default);
memory.relevant_recall = parse_bool_env(&relevant_recall);
}
if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
let memory = self.memory.get_or_insert_with(MemoryConfig::default);
memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
}
if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
let memory = self.memory.get_or_insert_with(MemoryConfig::default);
memory.project_first_dream = parse_bool_env(&project_first_dream);
}
if let Ok(key) = std::env::var("BAMBOO_OPENAI_API_KEY") {
let key = key.trim();
if !key.is_empty() {
let openai = self
.providers
.openai
.get_or_insert_with(OpenAIConfig::default);
openai.api_key = key.to_string();
openai.api_key_from_env = true;
}
}
if let Ok(key) = std::env::var("BAMBOO_ANTHROPIC_API_KEY") {
let key = key.trim();
if !key.is_empty() {
let anthropic = self
.providers
.anthropic
.get_or_insert_with(AnthropicConfig::default);
anthropic.api_key = key.to_string();
anthropic.api_key_from_env = true;
}
}
if let Ok(key) = std::env::var("BAMBOO_GEMINI_API_KEY") {
let key = key.trim();
if !key.is_empty() {
let gemini = self
.providers
.gemini
.get_or_insert_with(GeminiConfig::default);
gemini.api_key = key.to_string();
gemini.api_key_from_env = true;
}
}
}
pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
Self::from_data_dir_impl(data_dir, true, true)
}
pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
Self::from_data_dir_impl(data_dir, false, true)
}
pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
Self::from_data_dir_impl(data_dir, false, false)
}
fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
serde_json::from_str::<Config>(content).map(|mut config| {
config.hydrate_proxy_auth_from_encrypted();
config.hydrate_provider_api_keys_from_encrypted();
config.hydrate_provider_instance_api_keys_from_encrypted();
config.hydrate_mcp_secrets_from_encrypted();
config.hydrate_env_vars_from_encrypted();
config.hydrate_cluster_fabric_from_encrypted();
config.hydrate_broker_token_from_encrypted();
config.normalize_tool_settings();
config.normalize_skill_settings();
config
})
}
fn load_backup(data_dir: &std::path::Path) -> Option<Self> {
let config_path = data_dir.join("config.json");
for gen in 0..BAK_GENERATIONS {
let backup = backup_path_for(&config_path, gen);
let Ok(content) = std::fs::read_to_string(&backup) else {
continue;
};
match Self::parse_and_hydrate(&content) {
Ok(config) => {
tracing::info!("Recovered configuration from {:?}", backup);
return Some(config);
}
Err(e) => {
tracing::warn!(
"Backup {:?} is unparseable ({}); trying an older generation",
backup,
e
);
}
}
}
None
}
const SALVAGE_MAX_KEYS: usize = 512;
fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<Self> {
let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
let corrupt_obj = corrupt.as_object()?;
if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
tracing::warn!(
"config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
corrupt_obj.len(),
Self::SALVAGE_MAX_KEYS
);
return None;
}
let mut base = Self::load_backup(data_dir)
.and_then(|backup| serde_json::to_value(backup).ok())
.or_else(|| serde_json::to_value(Self::create_default()).ok())?;
let base_obj = base.as_object_mut()?;
let mut salvaged: Vec<String> = Vec::new();
for (key, value) in corrupt_obj {
let previous = base_obj.insert(key.clone(), value.clone());
if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
salvaged.push(key.clone());
} else {
match previous {
Some(prev) => {
base_obj.insert(key.clone(), prev);
}
None => {
base_obj.remove(key);
}
}
}
}
tracing::warn!(
"Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
last-known-good/default value",
salvaged.len(),
salvaged.join(", ")
);
let rebuilt = serde_json::to_string(&base).ok()?;
Self::parse_and_hydrate(&rebuilt).ok()
}
pub fn get_model(&self) -> Option<String> {
if self.features.provider_model_ref {
if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
return Some(model_ref.model.clone());
}
}
match self.provider.as_str() {
"openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
"anthropic" => self
.providers
.anthropic
.as_ref()
.and_then(|c| c.model.clone()),
"gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
"copilot" => Some(
self.providers
.copilot
.as_ref()
.and_then(|c| c.model.clone())
.unwrap_or_else(|| "gpt-4o".to_string()),
),
_ => None,
}
}
pub fn get_fast_model(&self) -> Option<String> {
if self.features.provider_model_ref {
if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
return Some(model_ref.model.clone());
}
}
let fast = match self.provider.as_str() {
"openai" => self
.providers
.openai
.as_ref()
.and_then(|c| c.fast_model.clone()),
"anthropic" => self
.providers
.anthropic
.as_ref()
.and_then(|c| c.fast_model.clone()),
"gemini" => self
.providers
.gemini
.as_ref()
.and_then(|c| c.fast_model.clone()),
"copilot" => self
.providers
.copilot
.as_ref()
.and_then(|c| c.fast_model.clone()),
_ => None,
};
fast.or_else(|| self.get_model())
}
pub fn get_task_summary_model(&self) -> Option<String> {
if self.features.provider_model_ref {
if let Some(model_ref) = self
.defaults
.as_ref()
.and_then(|d| d.task_summary.as_ref())
.or_else(|| {
self.defaults
.as_ref()
.and_then(|d| d.memory_background.as_ref())
})
.or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
.or_else(|| self.defaults.as_ref().map(|d| &d.chat))
{
return Some(model_ref.model.clone());
}
}
self.get_memory_background_model()
.or_else(|| self.get_model())
}
pub fn get_memory_background_model(&self) -> Option<String> {
if self.features.provider_model_ref {
if let Some(model_ref) = self
.defaults
.as_ref()
.and_then(|d| d.memory_background.as_ref())
{
return Some(model_ref.model.clone());
}
if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
return Some(model_ref.model.clone());
}
}
let configured = self
.memory
.as_ref()
.and_then(|memory| memory.background_model.as_ref())
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToString::to_string);
configured.or_else(|| match self.provider.as_str() {
"openai" => self
.providers
.openai
.as_ref()
.and_then(|c| c.fast_model.clone()),
"anthropic" => self
.providers
.anthropic
.as_ref()
.and_then(|c| c.fast_model.clone()),
"gemini" => self
.providers
.gemini
.as_ref()
.and_then(|c| c.fast_model.clone()),
"copilot" => self
.providers
.copilot
.as_ref()
.and_then(|c| c.fast_model.clone()),
_ => None,
})
}
pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
let raw = self
.default_work_area
.as_ref()
.and_then(|config| config.path.as_ref())
.map(|value| value.trim())
.filter(|value| !value.is_empty())?;
let candidate = expand_user_path(raw);
if candidate.is_absolute() {
let canonical = std::fs::canonicalize(&candidate).ok();
return canonical
.as_ref()
.filter(|path| path.is_dir())
.map(|_| candidate.clone())
.or_else(|| candidate.is_dir().then_some(candidate));
}
let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
canonical
.as_ref()
.filter(|path| path.is_dir())
.map(|_| from_bamboo_dir.clone())
.or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
.or_else(|| candidate.is_dir().then_some(candidate))
}
pub fn get_vision_model(&self) -> Option<String> {
let vision = match self.provider.as_str() {
"openai" => self
.providers
.openai
.as_ref()
.and_then(|c| c.vision_model.clone()),
"anthropic" => self
.providers
.anthropic
.as_ref()
.and_then(|c| c.vision_model.clone()),
"gemini" => self
.providers
.gemini
.as_ref()
.and_then(|c| c.vision_model.clone()),
"copilot" => self
.providers
.copilot
.as_ref()
.and_then(|c| c.vision_model.clone()),
_ => None,
};
vision.or_else(|| self.get_model())
}
pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
self.reasoning_effort_for_key(&self.provider)
}
pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
let trimmed = key.trim();
if trimmed.is_empty() {
return None;
}
if let Some(instance) = self.provider_instances.get(trimmed) {
return instance.reasoning_effort;
}
match trimmed {
"openai" => self
.providers
.openai
.as_ref()
.and_then(|c| c.reasoning_effort),
"anthropic" => self
.providers
.anthropic
.as_ref()
.and_then(|c| c.reasoning_effort),
"gemini" => self
.providers
.gemini
.as_ref()
.and_then(|c| c.reasoning_effort),
"copilot" => self
.providers
.copilot
.as_ref()
.and_then(|c| c.reasoning_effort),
"bodhi" => self
.providers
.bodhi
.as_ref()
.and_then(|c| c.reasoning_effort),
_ => None,
}
}
pub fn disabled_tool_names(&self) -> BTreeSet<String> {
self.tools
.disabled
.iter()
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
.collect()
}
pub fn normalize_tool_settings(&mut self) {
self.tools.disabled = self.disabled_tool_names().into_iter().collect();
}
pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
self.skills
.disabled
.iter()
.map(|id| id.trim())
.filter(|id| !id.is_empty())
.map(|id| id.to_string())
.collect()
}
pub fn normalize_skill_settings(&mut self) {
self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
}
pub fn effective_default_provider(&self) -> &str {
self.default_provider_instance
.as_deref()
.unwrap_or(&self.provider)
}
pub fn has_provider_instances(&self) -> bool {
!self.provider_instances.is_empty()
}
pub fn env_vars_as_map(&self) -> HashMap<String, String> {
self.env_vars
.iter()
.filter(|e| !e.value.trim().is_empty())
.map(|e| (e.name.clone(), e.value.clone()))
.collect()
}
fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
self.env_vars
.iter()
.filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
.map(|entry| PromptSafeEnvVarEntry {
name: entry.name.clone(),
secret: entry.secret,
description: entry
.description
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
})
.collect()
}
pub fn publish_env_vars(&self) {
let map = self.env_vars_as_map();
let mut env_guard = env_vars_cache().write().recover_poison();
*env_guard = map;
let prompt_safe = self.prompt_safe_env_vars();
let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
*prompt_guard = prompt_safe;
}
pub fn current_env_vars() -> HashMap<String, String> {
env_vars_cache().read().recover_poison().clone()
}
pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
prompt_safe_env_vars_cache().read().recover_poison().clone()
}
fn create_default() -> Self {
Config {
http_proxy: String::new(),
https_proxy: String::new(),
proxy_auth: None,
proxy_auth_encrypted: None,
headless_auth: false,
subagents: SubagentsConfig::default(),
cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
provider: default_provider(),
providers: ProviderConfigs::default(),
provider_instances: HashMap::new(),
default_provider_instance: None,
server: ServerConfig::default(),
keyword_masking: KeywordMaskingConfig::default(),
anthropic_model_mapping: AnthropicModelMapping::default(),
gemini_model_mapping: GeminiModelMapping::default(),
hooks: HooksConfig::default(),
tools: ToolsConfig::default(),
skills: SkillsConfig::default(),
env_vars: Vec::new(),
default_work_area: None,
access_control: None,
features: FeatureFlags::default(),
defaults: None,
memory: None,
mcp: bamboo_domain::mcp_config::McpConfig::default(),
extra: BTreeMap::new(),
}
}
pub fn server_addr(&self) -> String {
format!("{}:{}", self.server.bind, self.server.port)
}
pub fn save(&self) -> Result<()> {
self.save_to_dir(default_data_dir())
}
pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
let path = data_dir.join("config.json");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config dir: {:?}", parent))?;
}
let mut to_save = self.clone();
to_save.extra.remove("data_dir");
to_save.extra.remove("model");
to_save.refresh_proxy_auth_encrypted()?;
to_save.refresh_provider_api_keys_encrypted()?;
to_save.refresh_provider_instance_api_keys_encrypted()?;
to_save.refresh_env_vars_encrypted()?;
to_save.sanitize_env_vars_for_disk();
to_save.refresh_cluster_fabric_encrypted()?;
to_save.sanitize_cluster_fabric_for_disk();
to_save.normalize_tool_settings();
to_save.normalize_skill_settings();
let content =
serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
if path.exists()
&& std::fs::read_to_string(&path)
.ok()
.is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
{
rotate_backups(&path, BAK_GENERATIONS);
let backup = backup_path_for(&path, 0);
if let Err(e) = std::fs::copy(&path, &backup) {
tracing::warn!("Failed to back up config.json before save: {}", e);
}
}
write_atomic(&path, content.as_bytes())
.with_context(|| format!("Failed to write config file: {:?}", path))?;
Ok(())
}
}
const QUARANTINE_KEEP: usize = 5;
fn quarantine_corrupt_config(config_path: &std::path::Path) {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
let mut dedup = 1u32;
while quarantine.exists() {
quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
dedup += 1;
}
match std::fs::copy(config_path, &quarantine) {
Ok(_) => tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine),
Err(e) => tracing::error!("Failed to quarantine corrupt config.json: {}", e),
}
prune_quarantine_files(config_path, QUARANTINE_KEEP);
}
fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
let Some(dir) = config_path.parent() else {
return;
};
let prefix = "config.json.corrupted.";
let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(prefix))
})
.collect(),
Err(_) => return,
};
if quarantines.len() <= keep {
return;
}
quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
let remove = quarantines.len() - keep;
for stale in quarantines.into_iter().take(remove) {
if let Err(e) = std::fs::remove_file(&stale) {
tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
}
}
}
const BAK_GENERATIONS: usize = 3;
fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
if gen == 0 {
config_path.with_extension("json.bak")
} else {
config_path.with_extension(format!("json.bak.{gen}"))
}
}
fn rotate_backups(config_path: &std::path::Path, generations: usize) {
for gen in (1..generations).rev() {
let from = backup_path_for(config_path, gen - 1);
let to = backup_path_for(config_path, gen);
if from.exists() {
if let Err(e) = std::fs::rename(&from, &to) {
tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
}
}
}
}
fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
let Some(parent) = path.parent() else {
return std::fs::write(path, content);
};
std::fs::create_dir_all(parent)?;
let file_name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("config.json");
let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
let tmp_path = parent.join(tmp_name);
{
let mut file = std::fs::File::create(&tmp_path)?;
file.write_all(content)?;
file.sync_all()?;
}
std::fs::rename(&tmp_path, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
fn unset(key: &'static str) -> Self {
let previous = std::env::var_os(key);
std::env::remove_var(key);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn subagents_config_without_remote_placements_deserializes_empty() {
let json = r#"{ "max_concurrent": 4 }"#;
let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
assert_eq!(cfg.max_concurrent, Some(4));
assert!(cfg.remote_placements.is_empty());
let back = serde_json::to_string(&cfg).unwrap();
assert!(
!back.contains("remote_placements"),
"empty vec is skipped: {back}"
);
}
#[test]
fn remote_actor_placement_round_trips() {
let json = r#"{
"remote_placements": [
{
"role": "explorer",
"endpoint": "wss://gpu-host:8443",
"token_env": "WORKER_TOKEN",
"ca_cert_file": "/etc/bamboo/worker.pem"
},
{ "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
]
}"#;
let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
assert_eq!(cfg.remote_placements.len(), 2);
let p0 = &cfg.remote_placements[0];
assert_eq!(p0.role, "explorer");
assert_eq!(p0.endpoint, "wss://gpu-host:8443");
assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
let p1 = &cfg.remote_placements[1];
assert_eq!(p1.role, "writer");
assert!(p1.token_env.is_none());
assert!(p1.ca_cert_file.is_none());
let back = serde_json::to_string(&cfg).unwrap();
let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
assert_eq!(cfg, reparsed, "round-trip is stable");
assert!(!back.contains("\"token_env\":null"));
assert!(!back.contains("\"ca_cert_file\":null"));
}
#[test]
fn subagents_config_without_schedulable_placements_deserializes_empty() {
let json = r#"{ "max_concurrent": 4 }"#;
let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
assert!(cfg.schedulable_placements.is_empty());
let back = serde_json::to_string(&cfg).unwrap();
assert!(
!back.contains("schedulable_placements"),
"empty vec is skipped: {back}"
);
}
#[test]
fn schedulable_placement_round_trips() {
let json = r#"{
"schedulable_placements": [
{
"role": "explorer",
"pool": "gpu-pool",
"registry_url": "https://control-plane:9562",
"token_env": "WORKER_TOKEN",
"ca_cert_file": "/etc/bamboo/worker.pem"
},
{ "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
]
}"#;
let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
assert_eq!(cfg.schedulable_placements.len(), 2);
let p0 = &cfg.schedulable_placements[0];
assert_eq!(p0.role, "explorer");
assert_eq!(p0.pool, "gpu-pool");
assert_eq!(p0.registry_url, "https://control-plane:9562");
assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
let p1 = &cfg.schedulable_placements[1];
assert_eq!(p1.role, "writer");
assert_eq!(p1.pool, "cpu-pool");
assert!(p1.token_env.is_none());
assert!(p1.ca_cert_file.is_none());
let back = serde_json::to_string(&cfg).unwrap();
let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
assert_eq!(cfg, reparsed, "round-trip is stable");
assert!(!back.contains("\"token_env\":null"));
assert!(!back.contains("\"ca_cert_file\":null"));
}
#[test]
fn server_config_without_tls_field_deserializes_back_compat() {
let server: ServerConfig = serde_json::from_value(serde_json::json!({
"port": 9562,
"bind": "127.0.0.1"
}))
.expect("legacy server config without tls should deserialize");
assert_eq!(server.tls, None);
assert_eq!(server.port, 9562);
assert_eq!(server.bind, "127.0.0.1");
}
#[test]
fn server_config_omits_tls_when_none() {
let server = ServerConfig::default();
let value = serde_json::to_value(&server).expect("server config should serialize");
let obj = value
.as_object()
.expect("server config serializes to object");
assert!(
!obj.contains_key("tls"),
"tls must be omitted when None, got: {value}"
);
}
#[test]
fn server_config_with_tls_roundtrips() {
let server: ServerConfig = serde_json::from_value(serde_json::json!({
"port": 9562,
"bind": "0.0.0.0",
"tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
}))
.expect("server config with tls should deserialize");
let tls = server.tls.clone().expect("tls should be Some");
assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
let value = serde_json::to_value(&server).expect("serialize");
assert!(value.as_object().unwrap().contains_key("tls"));
let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
assert_eq!(back.tls, server.tls);
}
#[test]
fn access_control_without_devices_field_deserializes_back_compat() {
let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
"password_enabled": true,
"password_hash": "deadbeef",
"password_salt": "01020304",
}))
.expect("legacy access_control without devices should deserialize");
assert!(access.devices.is_empty());
assert!(access.password_enabled);
}
#[test]
fn access_control_omits_devices_when_empty() {
let access = AccessControlConfig {
password_enabled: true,
password_hash: Some("deadbeef".to_string()),
password_salt: Some("01020304".to_string()),
updated_at: None,
devices: Vec::new(),
};
let value = serde_json::to_value(&access).expect("serialize");
let obj = value.as_object().expect("object");
assert!(
!obj.contains_key("devices"),
"devices must be omitted when empty, got: {value}"
);
}
#[test]
fn access_control_with_devices_roundtrips() {
let device = DeviceCredential {
device_id: "bamboo_0123456789ab".to_string(),
label: "iPhone 15".to_string(),
token_hash: "abcd".to_string(),
token_salt: "ef01".to_string(),
created_at: "2026-06-23T00:00:00Z".to_string(),
last_used_at: None,
revoked: false,
};
let access = AccessControlConfig {
password_enabled: true,
password_hash: Some("deadbeef".to_string()),
password_salt: Some("01020304".to_string()),
updated_at: None,
devices: vec![device.clone()],
};
let value = serde_json::to_value(&access).expect("serialize");
assert!(value.as_object().unwrap().contains_key("devices"));
let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
assert_eq!(back.devices, vec![device]);
}
#[test]
fn reasoning_effort_for_key_resolves_instance_id() {
let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
"provider_type": "copilot",
"reasoning_effort": "high",
}))
.expect("instance config should deserialize");
let mut config = Config::create_default();
config
.provider_instances
.insert("copilot-work".to_string(), instance);
assert_eq!(
config.reasoning_effort_for_key("copilot-work"),
Some(ReasoningEffort::High),
);
}
#[test]
fn reasoning_effort_for_key_resolves_bodhi_legacy() {
let mut config = Config::create_default();
config.providers.bodhi = Some(
serde_json::from_value(serde_json::json!({
"reasoning_effort": "xhigh",
}))
.expect("bodhi config should deserialize"),
);
assert_eq!(
config.reasoning_effort_for_key("bodhi"),
Some(ReasoningEffort::Xhigh),
);
}
#[test]
fn reasoning_effort_for_key_resolves_legacy_provider_type() {
let mut config = Config::create_default();
config.providers.openai = Some(
serde_json::from_value(serde_json::json!({
"api_key": "sk-test",
"reasoning_effort": "low",
}))
.expect("openai config should deserialize"),
);
assert_eq!(
config.reasoning_effort_for_key("openai"),
Some(ReasoningEffort::Low),
);
}
#[test]
fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
let config = Config::create_default();
assert_eq!(config.reasoning_effort_for_key("nope"), None);
assert_eq!(config.reasoning_effort_for_key(" "), None);
}
struct TempHome {
path: PathBuf,
}
impl TempHome {
fn new() -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"chat-core-config-test-{}-{}",
std::process::id(),
nanos
));
std::fs::create_dir_all(&path).expect("failed to create temp home dir");
Self { path }
}
fn set_config_json(&self, content: &str) {
std::fs::create_dir_all(&self.path).expect("failed to create config dir");
std::fs::write(self.path.join("config.json"), content)
.expect("failed to write config.json");
}
}
impl Drop for TempHome {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn env_lock() -> &'static Mutex<()> {
crate::test_support::env_cache_lock()
}
fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
env_lock().lock().unwrap_or_else(|poisoned| {
poisoned.into_inner()
})
}
#[test]
fn parse_bool_env_true_values() {
for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
assert!(parse_bool_env(value), "value {value:?} should be true");
}
}
#[test]
fn parse_bool_env_false_values() {
for value in ["0", "false", "no", "off", "", " "] {
assert!(!parse_bool_env(value), "value {value:?} should be false");
}
}
#[test]
fn config_new_ignores_http_proxy_env_vars() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
temp_home.set_config_json(
r#"{
"http_proxy": "",
"https_proxy": ""
}"#,
);
let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
let config = Config::from_data_dir(Some(temp_home.path.clone()));
assert!(
config.http_proxy.is_empty(),
"config should ignore HTTP_PROXY env var"
);
assert!(
config.https_proxy.is_empty(),
"config should ignore HTTPS_PROXY env var"
);
}
#[test]
fn config_new_loads_config_when_proxy_fields_omitted() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
temp_home.set_config_json(
r#"{
"provider": "openai",
"providers": {
"openai": {
"api_key": "sk-test",
"model": "gpt-4o"
}
}
}"#,
);
let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
let config = Config::from_data_dir(Some(temp_home.path.clone()));
assert_eq!(
config
.providers
.openai
.as_ref()
.and_then(|c| c.model.as_deref()),
Some("gpt-4o"),
"config should load provider model from config file even when proxy fields are omitted"
);
assert!(config.http_proxy.is_empty());
assert!(config.https_proxy.is_empty());
}
#[test]
fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
let _lock = crate::test_support::env_cache_lock_acquire();
let config = Config {
env_vars: vec![
EnvVarEntry {
name: "SECRET_TOKEN".to_string(),
value: "top-secret".to_string(),
secret: true,
value_encrypted: None,
description: Some("Service token".to_string()),
},
EnvVarEntry {
name: "API_BASE".to_string(),
value: "https://internal.example".to_string(),
secret: false,
value_encrypted: None,
description: Some("Internal API base".to_string()),
},
],
..Default::default()
};
config.publish_env_vars();
let injected = Config::current_env_vars();
assert_eq!(
injected.get("SECRET_TOKEN").map(String::as_str),
Some("top-secret")
);
assert_eq!(
injected.get("API_BASE").map(String::as_str),
Some("https://internal.example")
);
let prompt_safe = Config::current_prompt_safe_env_vars();
assert_eq!(prompt_safe.len(), 2);
assert!(prompt_safe.iter().any(|entry| {
entry.name == "SECRET_TOKEN"
&& entry.secret
&& entry.description.as_deref() == Some("Service token")
}));
assert!(prompt_safe.iter().any(|entry| {
entry.name == "API_BASE"
&& !entry.secret
&& entry.description.as_deref() == Some("Internal API base")
}));
assert!(!prompt_safe
.iter()
.any(|entry| entry.name.contains("top-secret")));
assert!(!prompt_safe.iter().any(|entry| {
entry
.description
.as_deref()
.is_some_and(|value| value.contains("https://internal.example"))
}));
}
#[test]
fn from_data_dir_without_publish_does_not_clobber_global_cache() {
let _lock = crate::test_support::env_cache_lock_acquire();
Config {
env_vars: vec![EnvVarEntry {
name: "BAMBOO_CACHE_OWNER_40".to_string(),
value: "live".to_string(),
secret: false,
value_encrypted: None,
description: None,
}],
..Default::default()
}
.publish_env_vars();
assert_eq!(
Config::current_env_vars()
.get("BAMBOO_CACHE_OWNER_40")
.map(String::as_str),
Some("live")
);
let temp = TempHome::new();
temp.set_config_json(
&serde_json::json!({
"env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
})
.to_string(),
);
let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(
loaded
.env_vars
.iter()
.find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
.map(|e| e.value.as_str()),
Some("stale-disk"),
"the returned Config holds the disk value"
);
assert_eq!(
Config::current_env_vars()
.get("BAMBOO_CACHE_OWNER_40")
.map(String::as_str),
Some("live"),
"but the global cache is UNTOUCHED — no clobber (#40)"
);
let _ = Config::from_data_dir(Some(temp.path.clone()));
assert_eq!(
Config::current_env_vars()
.get("BAMBOO_CACHE_OWNER_40")
.map(String::as_str),
Some("stale-disk"),
"the publishing loader clobbers the cache (contrast)"
);
}
fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
std::fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.any(|e| {
e.file_name()
.to_string_lossy()
.contains("config.json.corrupted.")
})
}
#[test]
fn corrupt_config_recovered_from_backup_and_quarantined() {
let temp = TempHome::new();
std::fs::write(
temp.path.join("config.json.bak"),
serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
)
.unwrap();
std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(
config.http_proxy, "http://from-backup",
"recovered from config.json.bak instead of losing all config"
);
assert!(
dir_has_quarantine_file(&temp.path),
"corrupt config.json was quarantined (preserved), not discarded"
);
}
#[test]
fn corrupt_config_without_backup_quarantines_then_defaults() {
let temp = TempHome::new();
std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(
config.http_proxy.is_empty(),
"no backup -> falls back to defaults"
);
assert!(
dir_has_quarantine_file(&temp.path),
"corrupt config.json is quarantined even when there's no backup"
);
}
#[test]
fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
let temp = TempHome::new();
temp.set_config_json(
r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
);
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(
config.http_proxy, "http://salvaged",
"the valid field was salvaged from a partially-corrupt config (no .bak existed)"
);
assert!(
config.env_vars.is_empty(),
"the corrupt field reset to its default instead of failing the whole load"
);
assert!(
dir_has_quarantine_file(&temp.path),
"the corrupt config.json was still quarantined for inspection"
);
}
#[test]
fn salvage_preferred_over_backup_for_most_recent_intent() {
let temp = TempHome::new();
std::fs::write(
temp.path.join("config.json.bak"),
serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
)
.unwrap();
temp.set_config_json(
r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
);
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(
config.http_proxy, "http://new-salvaged",
"salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
);
}
#[test]
fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
let temp = TempHome::new();
std::fs::write(
temp.path.join("config.json.bak"),
serde_json::json!({
"http_proxy": "http://old-from-backup",
"https_proxy": "https://kept-from-backup",
})
.to_string(),
)
.unwrap();
temp.set_config_json(
r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
);
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(
config.http_proxy, "http://newer-edit",
"the corrupt file's newer valid edit is applied"
);
assert_eq!(
config.https_proxy, "https://kept-from-backup",
"the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
);
}
#[test]
fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
let temp = TempHome::new();
std::fs::write(
temp.path.join("config.json.bak"),
serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
)
.unwrap();
std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(
config.http_proxy, "http://from-backup",
"garbage (non-object) config skips salvage and recovers from .bak"
);
}
#[test]
fn quarantine_files_are_capped_to_newest_n() {
let temp = TempHome::new();
let config_path = temp.path.join("config.json");
std::fs::write(&config_path, "{}").unwrap();
for _ in 0..(QUARANTINE_KEEP + 3) {
quarantine_corrupt_config(&config_path);
std::thread::sleep(std::time::Duration::from_millis(3));
}
let count = std::fs::read_dir(&temp.path)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("config.json.corrupted.")
})
.count();
assert_eq!(
count, QUARANTINE_KEEP,
"old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
);
}
#[test]
fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
let temp = TempHome::new();
std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
std::fs::write(
temp.path.join("config.json.bak.1"),
serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
)
.unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(
config.http_proxy, "http://from-gen-1",
"recovered from .bak.1 when both config.json and .bak are corrupt"
);
}
#[test]
fn save_rotates_backup_generations() {
let temp = TempHome::new();
let path = temp.path.join("config.json");
std::fs::write(
&path,
serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
)
.unwrap();
let mut cfg = Config::create_default();
cfg.http_proxy = "http://proxy-v2".to_string();
cfg.save_to_dir(temp.path.clone()).unwrap();
cfg.http_proxy = "http://proxy-v3".to_string();
cfg.save_to_dir(temp.path.clone()).unwrap();
let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
assert!(
bak.contains("proxy-v2"),
".bak holds the previous generation (v2)"
);
assert!(
bak1.contains("proxy-v1"),
".bak.1 holds the older rotated generation (v1)"
);
}
#[test]
fn save_backs_up_existing_config() {
let temp = TempHome::new();
std::fs::write(
temp.path.join("config.json"),
serde_json::json!({ "http_proxy": "http://old" }).to_string(),
)
.unwrap();
let mut config = Config::create_default();
config.http_proxy = "http://new".to_string();
config
.save_to_dir(temp.path.clone())
.expect("save succeeds");
let backup =
std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
assert!(
backup.contains("http://old"),
"config.json.bak holds the PREVIOUS config (last-known-good)"
);
let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
assert!(
current.contains("http://new"),
"config.json holds the new config"
);
}
#[test]
fn save_does_not_overwrite_good_backup_with_corrupt_config() {
let temp = TempHome::new();
std::fs::write(
temp.path.join("config.json.bak"),
serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
)
.unwrap();
std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
let mut config = Config::create_default();
config.http_proxy = "http://new".to_string();
config
.save_to_dir(temp.path.clone())
.expect("save succeeds");
let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
assert!(
backup.contains("http://good-bak"),
"good last-known-good backup is preserved (not overwritten by corrupt config.json)"
);
}
#[test]
fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
temp_home.set_config_json(
r#"{
"provider": "openai",
"providers": {
"openai": {
"api_key": "sk-test",
"model": "gpt-4o"
}
}
}"#,
);
let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
let config = Config::from_data_dir(Some(temp_home.path.clone()));
assert_eq!(
config
.providers
.openai
.as_ref()
.and_then(|c| c.model.as_deref()),
Some("gpt-4o")
);
assert!(
config.http_proxy.is_empty(),
"config should keep http_proxy empty when field is omitted"
);
assert!(
config.https_proxy.is_empty(),
"config should keep https_proxy empty when field is omitted"
);
}
#[test]
fn get_memory_background_model_prefers_memory_specific_override() {
let mut config = Config::default();
config.features.provider_model_ref = false;
config.provider = "openai".to_string();
config.providers.openai = Some(OpenAIConfig {
api_key: "test".to_string(),
api_key_encrypted: None,
base_url: None,
model: Some("gpt-main".to_string()),
fast_model: Some("gpt-fast".to_string()),
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: BTreeMap::new(),
api_key_from_env: false,
});
config.memory = Some(MemoryConfig {
background_model: Some("memory-fast".to_string()),
..MemoryConfig::default()
});
assert_eq!(
config.get_memory_background_model().as_deref(),
Some("memory-fast")
);
}
#[test]
fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
let openai = |api_key: &str, from_env: bool| OpenAIConfig {
api_key: api_key.to_string(),
api_key_encrypted: None,
base_url: None,
model: None,
fast_model: None,
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: BTreeMap::new(),
api_key_from_env: from_env,
};
let mut current = Config::default();
current.providers.openai = Some(openai("sk-env", true));
let mut merged = Config::default();
merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
let got = merged.providers.openai.as_ref().unwrap();
assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
assert!(got.api_key_from_env, "env flag restored");
let mut merged = Config::default();
merged.providers.openai = Some(openai("sk-explicit", false));
merged.preserve_env_sourced_provider_keys(¤t);
assert_eq!(
merged.providers.openai.as_ref().unwrap().api_key,
"sk-explicit",
"explicit patch key must win"
);
let mut current_plain = Config::default();
current_plain.providers.openai = Some(openai("sk-plain", false));
let mut merged = Config::default();
merged.providers.openai = Some(openai("", false));
merged.preserve_env_sourced_provider_keys(¤t_plain);
assert!(
merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
"non-env key must not be restored by this path"
);
}
#[test]
fn refresh_preserves_ciphertext_when_plaintext_empty() {
let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
api_key: api_key.to_string(),
api_key_encrypted: enc.map(str::to_string),
base_url: None,
model: None,
fast_model: None,
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: BTreeMap::new(),
api_key_from_env: false,
};
let mut config = Config::default();
config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
config
.refresh_provider_api_keys_encrypted()
.expect("refresh");
assert_eq!(
config
.providers
.openai
.as_ref()
.unwrap()
.api_key_encrypted
.as_deref(),
Some("preexisting-ciphertext"),
"existing ciphertext must be preserved when plaintext is empty"
);
let mut config = Config::default();
config.providers.openai = Some(openai("", None));
config
.refresh_provider_api_keys_encrypted()
.expect("refresh");
assert!(
config
.providers
.openai
.as_ref()
.unwrap()
.api_key_encrypted
.is_none(),
"no key + no ciphertext should stay None"
);
let mut config = Config::default();
config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
config
.refresh_provider_api_keys_encrypted()
.expect("refresh");
let enc = config
.providers
.openai
.as_ref()
.unwrap()
.api_key_encrypted
.clone()
.expect("ciphertext present");
assert!(
!enc.is_empty() && enc != "stale-ciphertext",
"plaintext re-encrypted"
);
}
#[test]
fn get_memory_background_model_falls_back_to_provider_fast_model() {
let mut config = Config::default();
config.features.provider_model_ref = false;
config.provider = "openai".to_string();
config.providers.openai = Some(OpenAIConfig {
api_key: "test".to_string(),
api_key_encrypted: None,
base_url: None,
model: Some("gpt-main".to_string()),
fast_model: Some("gpt-fast".to_string()),
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: BTreeMap::new(),
api_key_from_env: false,
});
assert_eq!(
config.get_memory_background_model().as_deref(),
Some("gpt-fast")
);
}
#[test]
fn get_memory_background_model_does_not_fall_back_to_main_model() {
let mut config = Config::default();
config.features.provider_model_ref = false;
config.provider = "openai".to_string();
config.providers.openai = Some(OpenAIConfig {
api_key: "test".to_string(),
api_key_encrypted: None,
base_url: None,
model: Some("gpt-main".to_string()),
fast_model: None,
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: BTreeMap::new(),
api_key_from_env: false,
});
assert!(config.get_memory_background_model().is_none());
}
#[test]
fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
let config = Config {
memory: Some(MemoryConfig {
background_model: Some("dream-fast".to_string()),
auto_dream_enabled: true,
auto_dream_interval_secs: 900,
project_prompt_injection: false,
relevant_recall: false,
relevant_recall_rerank: true,
project_first_dream: false,
dream_refine_mode: true,
gardener_enabled: true,
gardener_interval_secs: 3_600,
gardener_volume_trigger: 40,
gardener_max_splits_per_run: 4,
gardener_min_sections: 7,
dedup_gardener_enabled: true,
dedup_gardener_min_score: 0.7,
dedup_gardener_max_merges_per_run: 3,
memory_active_capacity: 500,
capacity_max_archivals_per_run: 10,
}),
..Config::default()
};
let serialized = serde_json::to_string(&config).expect("config should serialize");
let roundtrip: Config =
serde_json::from_str(&serialized).expect("config should deserialize");
let memory = roundtrip.memory.expect("memory config should exist");
assert!(memory.auto_dream_enabled);
assert!(!memory.project_prompt_injection);
assert!(!memory.relevant_recall);
assert!(memory.relevant_recall_rerank);
assert!(!memory.project_first_dream);
assert!(memory.dream_refine_mode);
assert!(memory.gardener_enabled);
assert_eq!(memory.gardener_interval_secs, 3_600);
assert_eq!(memory.gardener_volume_trigger, 40);
assert_eq!(memory.gardener_max_splits_per_run, 4);
assert_eq!(memory.gardener_min_sections, 7);
assert!(memory.dedup_gardener_enabled);
assert_eq!(memory.dedup_gardener_min_score, 0.7);
assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
assert_eq!(memory.memory_active_capacity, 500);
assert_eq!(memory.capacity_max_archivals_per_run, 10);
}
#[test]
fn memory_active_capacity_defaults_off() {
assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
let memory = parsed.memory.unwrap();
assert_eq!(memory.memory_active_capacity, 0);
assert_eq!(
memory.capacity_max_archivals_per_run, 50,
"omitted field takes the serde default fn"
);
}
#[test]
fn memory_maintenance_integrators_default_on() {
let defaults = MemoryConfig::default();
assert!(defaults.auto_dream_enabled);
assert!(defaults.gardener_enabled);
assert!(defaults.dedup_gardener_enabled);
assert_eq!(defaults.gardener_volume_trigger, 25);
let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
let memory = parsed.memory.expect("memory present");
assert!(
memory.auto_dream_enabled,
"auto_dream on when field omitted"
);
assert!(memory.gardener_enabled, "gardener on when field omitted");
assert!(
memory.dedup_gardener_enabled,
"dedup gardener on when field omitted"
);
let opted_out: Config =
serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
assert!(!opted_out.memory.unwrap().gardener_enabled);
}
#[test]
fn memory_config_env_overrides_prompt_flags() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
let _relevant_recall_rerank =
EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
let config = Config::from_data_dir(Some(temp_home.path.clone()));
let memory = config
.memory
.expect("memory config should be created by env overrides");
assert!(!memory.project_prompt_injection);
assert!(!memory.relevant_recall);
assert!(memory.relevant_recall_rerank);
assert!(!memory.project_first_dream);
}
#[test]
fn provider_api_keys_injected_from_env_and_never_persisted() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
let _anthropic = EnvVarGuard::set("BAMBOO_ANTHROPIC_API_KEY", "sk-ant-from-env");
let _openai = EnvVarGuard::set("BAMBOO_OPENAI_API_KEY", "sk-oai-from-env");
let config = Config::from_data_dir(Some(temp_home.path.clone()));
assert_eq!(
config
.providers
.anthropic
.as_ref()
.expect("anthropic created from env")
.api_key,
"sk-ant-from-env"
);
assert_eq!(
config
.providers
.openai
.as_ref()
.expect("openai created from env")
.api_key,
"sk-oai-from-env"
);
assert!(config.providers.gemini.is_none());
config
.save_to_dir(temp_home.path.clone())
.expect("save config");
let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
.expect("read persisted config.json");
assert!(
!on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
"env key must not be persisted as plaintext"
);
let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
assert!(
disk_json["providers"]["anthropic"]
.get("api_key_encrypted")
.is_none(),
"env-sourced anthropic key must not be re-encrypted into config.json"
);
assert!(
disk_json["providers"]["openai"]
.get("api_key_encrypted")
.is_none(),
"env-sourced openai key must not be re-encrypted into config.json"
);
drop(_anthropic);
drop(_openai);
let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
assert!(reloaded
.providers
.anthropic
.as_ref()
.map(|a| a.api_key.is_empty())
.unwrap_or(true));
}
#[test]
fn get_default_work_area_path_expands_tilde_and_requires_directory() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
let target = temp_home.path.join("workspace-default");
std::fs::create_dir_all(&target).expect("default work area dir should exist");
let config = Config {
default_work_area: Some(DefaultWorkAreaConfig {
path: Some("~/workspace-default".to_string()),
}),
..Default::default()
};
assert_eq!(config.get_default_work_area_path(), Some(target));
}
#[test]
fn get_default_work_area_path_returns_none_for_missing_directory() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
let config = Config {
default_work_area: Some(DefaultWorkAreaConfig {
path: Some("~/missing-default-work-area".to_string()),
}),
..Default::default()
};
assert!(config.get_default_work_area_path().is_none());
}
#[test]
fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
let mut config = Config::default();
config.tools.disabled = vec![
" read_file ".to_string(),
"".to_string(),
"read_file".to_string(),
"bash".to_string(),
"default::getCurrentDir".to_string(),
];
config.normalize_tool_settings();
assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
}
#[test]
fn config_load_reads_disabled_tools_as_canonical_names() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
temp_home.set_config_json(
r#"{
"tools": {
"disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
}
}"#,
);
let config = Config::from_data_dir(Some(temp_home.path.clone()));
assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
assert!(config.disabled_tool_names().contains("Bash"));
assert!(config.disabled_tool_names().contains("Read"));
assert!(config.disabled_tool_names().contains("GetCurrentDir"));
}
#[test]
fn normalize_skill_settings_trims_dedupes_and_sorts() {
let mut config = Config::default();
config.skills.disabled = vec![
" pdf ".to_string(),
"".to_string(),
"pdf".to_string(),
"skill-creator".to_string(),
];
config.normalize_skill_settings();
assert_eq!(
config.skills.disabled,
vec!["pdf".to_string(), "skill-creator".to_string()]
);
}
#[test]
fn config_load_reads_disabled_skills_as_normalized_ids() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
temp_home.set_config_json(
r#"{
"skills": {
"disabled": [" pdf ", "skill-creator", "pdf", ""]
}
}"#,
);
let config = Config::from_data_dir(Some(temp_home.path.clone()));
assert_eq!(
config.skills.disabled,
vec!["pdf".to_string(), "skill-creator".to_string()]
);
assert!(config.disabled_skill_ids().contains("pdf"));
assert!(config.disabled_skill_ids().contains("skill-creator"));
}
#[test]
fn test_server_config_defaults() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let config = Config::from_data_dir(Some(temp_home.path.clone()));
assert_eq!(config.server.port, 9562);
assert_eq!(config.server.bind, "127.0.0.1");
assert_eq!(config.server.workers, 10);
assert!(config.server.static_dir.is_none());
}
#[test]
fn test_server_addr() {
let mut config = Config::default();
config.server.port = 9000;
config.server.bind = "0.0.0.0".to_string();
assert_eq!(config.server_addr(), "0.0.0.0:9000");
}
#[test]
fn test_env_var_overrides() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
let config = Config::from_data_dir(Some(temp_home.path.clone()));
assert_eq!(config.server.port, 9999);
assert_eq!(config.server.bind, "192.168.1.1");
assert_eq!(config.provider, "openai");
}
#[test]
fn test_config_save_and_load() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
config.server.port = 9000;
config.server.bind = "0.0.0.0".to_string();
config.provider = "anthropic".to_string();
config
.save_to_dir(temp_home.path.clone())
.expect("Failed to save config");
let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
assert_eq!(loaded.server.port, 9000);
assert_eq!(loaded.server.bind, "0.0.0.0");
assert_eq!(loaded.provider, "anthropic");
}
#[test]
fn config_decrypts_proxy_auth_from_encrypted_field() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let key_guard = crate::encryption::set_test_encryption_key([
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
0x1c, 0x1d, 0x1e, 0x1f,
]);
let auth = ProxyAuth {
username: "user".to_string(),
password: "pass".to_string(),
};
let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
temp_home.set_config_json(&format!(
r#"{{
"http_proxy": "http://proxy.example.com:8080",
"proxy_auth_encrypted": "{encrypted}"
}}"#
));
let config = Config::from_data_dir(Some(temp_home.path.clone()));
let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
assert_eq!(loaded_auth.username, "user");
assert_eq!(loaded_auth.password, "pass");
drop(key_guard);
}
#[test]
fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let key_guard = crate::encryption::set_test_encryption_key([
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
0x1c, 0x1d, 0x1e, 0x1f,
]);
let auth = ProxyAuth {
username: "user".to_string(),
password: "pass".to_string(),
};
let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
temp_home.set_config_json(&format!(
r#"{{
"http_proxy": "http://proxy.example.com:8080",
"http_proxy_auth_encrypted": "{encrypted}",
"https_proxy_auth_encrypted": "{encrypted}"
}}"#
));
let config = Config::from_data_dir(Some(temp_home.path.clone()));
let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
assert_eq!(loaded_auth.username, "user");
assert_eq!(loaded_auth.password, "pass");
drop(key_guard);
}
#[test]
fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let key_guard = crate::encryption::set_test_encryption_key([
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
0x1c, 0x1d, 0x1e, 0x1f,
]);
let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
config.proxy_auth = Some(ProxyAuth {
username: "user".to_string(),
password: "pass".to_string(),
});
config
.save_to_dir(temp_home.path.clone())
.expect("save should encrypt proxy auth");
let content =
std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
assert!(
content.contains("proxy_auth_encrypted"),
"config.json should store encrypted proxy auth"
);
assert!(
!content.contains("\"proxy_auth\""),
"config.json should not store plaintext proxy_auth"
);
let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
assert_eq!(loaded_auth.username, "user");
assert_eq!(loaded_auth.password, "pass");
drop(key_guard);
}
#[test]
fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let key_guard = crate::encryption::set_test_encryption_key([
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
0x1c, 0x1d, 0x1e, 0x1f,
]);
let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
config.provider = "openai".to_string();
config.providers.openai = Some(OpenAIConfig {
api_key: "sk-test-provider-key".to_string(),
api_key_encrypted: None,
base_url: None,
model: None,
fast_model: None,
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: Default::default(),
api_key_from_env: false,
});
config
.save_to_dir(temp_home.path.clone())
.expect("save should encrypt provider api keys");
let content =
std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
assert!(
content.contains("\"api_key_encrypted\""),
"config.json should store encrypted provider keys"
);
assert!(
!content.contains("\"api_key\""),
"config.json should not store plaintext provider keys"
);
let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
let openai = loaded
.providers
.openai
.expect("openai config should be present");
assert_eq!(openai.api_key, "sk-test-provider-key");
drop(key_guard);
}
#[test]
fn config_save_persists_mcp_servers_in_mainstream_format() {
let _lock = env_lock_acquire();
let temp_home = TempHome::new();
let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
let mut env = std::collections::HashMap::new();
env.insert("TOKEN".to_string(), "supersecret".to_string());
config.mcp.servers = vec![
bamboo_domain::mcp_config::McpServerConfig {
id: "stdio-secret".to_string(),
name: None,
enabled: true,
transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
bamboo_domain::mcp_config::StdioConfig {
command: "echo".to_string(),
args: vec![],
cwd: None,
env,
env_encrypted: std::collections::HashMap::new(),
startup_timeout_ms: 5000,
},
),
request_timeout_ms: 5000,
healthcheck_interval_ms: 1000,
reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
allowed_tools: vec![],
denied_tools: vec![],
},
bamboo_domain::mcp_config::McpServerConfig {
id: "sse-secret".to_string(),
name: None,
enabled: true,
transport: bamboo_domain::mcp_config::TransportConfig::Sse(
bamboo_domain::mcp_config::SseConfig {
url: "http://localhost:8080/sse".to_string(),
headers: vec![bamboo_domain::mcp_config::HeaderConfig {
name: "Authorization".to_string(),
value: "Bearer token123".to_string(),
value_encrypted: None,
}],
connect_timeout_ms: 5000,
},
),
request_timeout_ms: 5000,
healthcheck_interval_ms: 1000,
reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
allowed_tools: vec![],
denied_tools: vec![],
},
];
config
.save_to_dir(temp_home.path.clone())
.expect("save should persist MCP servers");
let content =
std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
assert!(
content.contains("\"mcpServers\""),
"config.json should store MCP servers under the mainstream 'mcpServers' key"
);
assert!(
content.contains("supersecret"),
"config.json should persist MCP stdio env in mainstream format"
);
assert!(
content.contains("Bearer token123"),
"config.json should persist MCP SSE headers in mainstream format"
);
assert!(
!content.contains("\"env_encrypted\""),
"config.json should not persist legacy env_encrypted fields"
);
assert!(
!content.contains("\"value_encrypted\""),
"config.json should not persist legacy value_encrypted fields"
);
let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
let stdio = loaded
.mcp
.servers
.iter()
.find(|s| s.id == "stdio-secret")
.expect("stdio server should exist");
match &stdio.transport {
bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
assert_eq!(
stdio.env.get("TOKEN").map(|s| s.as_str()),
Some("supersecret")
);
}
_ => panic!("Expected stdio transport"),
}
let sse = loaded
.mcp
.servers
.iter()
.find(|s| s.id == "sse-secret")
.expect("sse server should exist");
match &sse.transport {
bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
assert_eq!(sse.headers[0].value, "Bearer token123");
}
_ => panic!("Expected SSE transport"),
}
}
#[test]
fn env_vars_as_map_includes_only_non_empty_values() {
let config = Config {
env_vars: vec![
EnvVarEntry {
name: "A".to_string(),
value: "val_a".to_string(),
secret: false,
value_encrypted: None,
description: None,
},
EnvVarEntry {
name: "B".to_string(),
value: "".to_string(), secret: true,
value_encrypted: None,
description: None,
},
EnvVarEntry {
name: "C".to_string(),
value: " ".to_string(), secret: false,
value_encrypted: None,
description: None,
},
EnvVarEntry {
name: "D".to_string(),
value: "val_d".to_string(),
secret: true,
value_encrypted: Some("enc".to_string()),
description: Some("desc".to_string()),
},
],
..Default::default()
};
let map = config.env_vars_as_map();
assert_eq!(map.len(), 2);
assert_eq!(map.get("A"), Some(&"val_a".to_string()));
assert_eq!(map.get("D"), Some(&"val_d".to_string()));
assert!(!map.contains_key("B"));
assert!(!map.contains_key("C"));
}
#[test]
fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
let mut config = Config {
env_vars: vec![
EnvVarEntry {
name: "PLAIN".to_string(),
value: "visible".to_string(),
secret: false,
value_encrypted: None,
description: None,
},
EnvVarEntry {
name: "SECRET".to_string(),
value: "hidden_value".to_string(),
secret: true,
value_encrypted: Some("enc_data".to_string()),
description: None,
},
],
..Default::default()
};
config.sanitize_env_vars_for_disk();
assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
#[test]
fn sanitize_env_vars_for_disk_preserves_encrypted() {
let mut config = Config {
env_vars: vec![
EnvVarEntry {
name: "OPEN".to_string(),
value: "val".to_string(),
secret: false,
value_encrypted: None,
description: None,
},
EnvVarEntry {
name: "HIDDEN".to_string(),
value: "real_secret".to_string(),
secret: true,
value_encrypted: Some("enc".to_string()),
description: None,
},
],
..Default::default()
};
config.sanitize_env_vars_for_disk();
assert_eq!(config.env_vars[0].value, "val");
assert_eq!(config.env_vars[1].value, "");
assert_eq!(config.env_vars[1].value_encrypted.as_deref(), Some("enc"));
}
#[test]
fn refresh_env_vars_encrypted_round_trip() {
let mut config = Config {
env_vars: vec![
EnvVarEntry {
name: "TOKEN".to_string(),
value: "my-secret-token".to_string(),
secret: true,
value_encrypted: None,
description: Some("A token".to_string()),
},
EnvVarEntry {
name: "PLAIN_VAR".to_string(),
value: "hello".to_string(),
secret: false,
value_encrypted: None,
description: None,
},
],
..Default::default()
};
config
.refresh_env_vars_encrypted()
.expect("encryption should succeed");
assert!(config.env_vars[0].value_encrypted.is_some());
assert!(config.env_vars[1].value_encrypted.is_none());
let encrypted = config.env_vars[0].value_encrypted.clone().unwrap();
assert_ne!(encrypted, "my-secret-token");
config.sanitize_env_vars_for_disk();
assert_eq!(config.env_vars[0].value, "");
config.hydrate_env_vars_from_encrypted();
assert_eq!(config.env_vars[0].value, "my-secret-token");
assert_eq!(config.env_vars[1].value, "hello"); }
#[test]
fn publish_and_current_env_vars_round_trip() {
let config = Config {
env_vars: vec![EnvVarEntry {
name: "TEST_PUBLISH".to_string(),
value: "pub_value".to_string(),
secret: false,
value_encrypted: None,
description: None,
}],
..Default::default()
};
for _ in 0..10 {
config.publish_env_vars();
let map = Config::current_env_vars();
if map.get("TEST_PUBLISH") == Some(&"pub_value".to_string()) {
return;
}
}
panic!("TEST_PUBLISH not found in cache after retries");
}
#[test]
fn broker_token_round_trips_encrypt_sanitize_hydrate() {
let mut config = Config::default();
config.subagents.broker = Some(BrokerClientConfig {
endpoint: "ws://127.0.0.1:9600".to_string(),
token: "super-secret-token".to_string(),
token_encrypted: None,
});
config.refresh_broker_token_encrypted().unwrap();
config.sanitize_broker_token_for_disk();
let broker = config.subagents.broker.as_ref().unwrap();
assert!(broker.token.is_empty(), "plaintext cleared for disk");
assert!(broker.token_encrypted.is_some(), "ciphertext stored");
assert_ne!(
broker.token_encrypted.as_deref(),
Some("super-secret-token")
);
config.hydrate_broker_token_from_encrypted();
assert_eq!(
config.subagents.broker.as_ref().unwrap().token,
"super-secret-token"
);
}
#[test]
fn broker_token_empty_refresh_preserves_ciphertext() {
let mut config = Config::default();
config.subagents.broker = Some(BrokerClientConfig {
endpoint: "ws://h:9600".to_string(),
token: String::new(),
token_encrypted: Some("existing-cipher".to_string()),
});
config.refresh_broker_token_encrypted().unwrap();
assert_eq!(
config
.subagents
.broker
.as_ref()
.unwrap()
.token_encrypted
.as_deref(),
Some("existing-cipher"),
);
}
#[test]
fn hydrate_skips_non_secret_entries() {
let mut config = Config {
env_vars: vec![EnvVarEntry {
name: "PLAIN".to_string(),
value: "original".to_string(),
secret: false,
value_encrypted: Some("should-be-ignored".to_string()),
description: None,
}],
..Default::default()
};
config.hydrate_env_vars_from_encrypted();
assert_eq!(config.env_vars[0].value, "original");
}
#[test]
fn default_config_has_empty_env_vars() {
assert!(Config::default().env_vars.is_empty());
}
#[test]
fn serde_round_trip_with_env_vars() {
let config = Config {
env_vars: vec![
EnvVarEntry {
name: "KEY1".to_string(),
value: "val1".to_string(),
secret: false,
value_encrypted: None,
description: Some("First key".to_string()),
},
EnvVarEntry {
name: "KEY2".to_string(),
value: "".to_string(), secret: true,
value_encrypted: Some("enc123".to_string()),
description: None,
},
],
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let restored: Config = serde_json::from_str(&json).unwrap();
assert_eq!(restored.env_vars.len(), 2);
assert_eq!(restored.env_vars[0].name, "KEY1");
assert_eq!(restored.env_vars[0].value, "val1");
assert!(!restored.env_vars[0].secret);
assert_eq!(restored.env_vars[1].name, "KEY2");
assert!(restored.env_vars[1].secret);
assert_eq!(
restored.env_vars[1].value_encrypted.as_deref(),
Some("enc123")
);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
let mut config = Config::default();
config.provider = "openai".to_string();
config.providers.openai = Some(OpenAIConfig {
api_key: "test".to_string(),
api_key_encrypted: None,
base_url: None,
model: Some("legacy-gpt-4o".to_string()),
fast_model: None,
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: Default::default(),
api_key_from_env: false,
});
config.features.provider_model_ref = true;
config.defaults = Some(DefaultsConfig {
chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
fast: None,
task_summary: None,
vision: None,
memory_background: None,
planning: None,
search: None,
code_review: None,
sub_agent: None,
subagent_models: Default::default(),
});
assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
let mut config = Config::default();
config.provider = "openai".to_string();
config.providers.openai = Some(OpenAIConfig {
api_key: "test".to_string(),
api_key_encrypted: None,
base_url: None,
model: Some("legacy-gpt-4o".to_string()),
fast_model: None,
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: Default::default(),
api_key_from_env: false,
});
config.features.provider_model_ref = false;
config.defaults = Some(DefaultsConfig {
chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
fast: None,
task_summary: None,
vision: None,
memory_background: None,
planning: None,
search: None,
code_review: None,
sub_agent: None,
subagent_models: Default::default(),
});
assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
let mut config = Config::default();
config.provider = "openai".to_string();
config.providers.openai = Some(OpenAIConfig {
api_key: "test".to_string(),
api_key_encrypted: None,
base_url: None,
model: Some("gpt-4o".to_string()),
fast_model: Some("legacy-gpt-4o-mini".to_string()),
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: Default::default(),
api_key_from_env: false,
});
config.features.provider_model_ref = true;
config.defaults = Some(DefaultsConfig {
chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
fast: Some(bamboo_domain::ProviderModelRef::new(
"anthropic",
"claude-3-5-haiku",
)),
task_summary: None,
vision: None,
memory_background: None,
planning: None,
search: None,
code_review: None,
sub_agent: None,
subagent_models: Default::default(),
});
assert_eq!(
config.get_fast_model(),
Some("claude-3-5-haiku".to_string())
);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
let mut config = Config::default();
config.provider = "openai".to_string();
config.providers.openai = Some(OpenAIConfig {
api_key: "test".to_string(),
api_key_encrypted: None,
base_url: None,
model: Some("gpt-4o".to_string()),
fast_model: Some("legacy-gpt-4o-mini".to_string()),
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: Default::default(),
api_key_from_env: false,
});
config.features.provider_model_ref = false;
config.defaults = Some(DefaultsConfig {
chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
fast: Some(bamboo_domain::ProviderModelRef::new(
"anthropic",
"claude-3-5-haiku",
)),
task_summary: None,
vision: None,
memory_background: None,
planning: None,
search: None,
code_review: None,
sub_agent: None,
subagent_models: Default::default(),
});
assert_eq!(
config.get_fast_model(),
Some("legacy-gpt-4o-mini".to_string())
);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
let mut config = Config::default();
config.provider = "openai".to_string();
config.features.provider_model_ref = true;
config.defaults = Some(DefaultsConfig {
chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
fast: None,
task_summary: None,
vision: None,
memory_background: None,
planning: None,
search: None,
code_review: None,
sub_agent: None,
subagent_models: Default::default(),
});
assert_eq!(
config.get_fast_model(),
Some("claude-3-7-sonnet".to_string())
);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn get_memory_background_model_prefers_defaults_memory_background() {
let mut config = Config::default();
config.provider = "openai".to_string();
config.providers.openai = Some(OpenAIConfig {
api_key: "test".to_string(),
api_key_encrypted: None,
base_url: None,
model: Some("gpt-4o".to_string()),
fast_model: Some("gpt-4o-mini".to_string()),
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: Default::default(),
api_key_from_env: false,
});
config.features.provider_model_ref = true;
config.defaults = Some(DefaultsConfig {
chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
fast: Some(bamboo_domain::ProviderModelRef::new(
"openai",
"gpt-4o-mini",
)),
task_summary: None,
vision: None,
memory_background: Some(bamboo_domain::ProviderModelRef::new(
"anthropic",
"claude-3-5-haiku",
)),
planning: None,
search: None,
code_review: None,
sub_agent: None,
subagent_models: Default::default(),
});
assert_eq!(
config.get_memory_background_model(),
Some("claude-3-5-haiku".to_string())
);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
let mut config = Config::default();
config.provider = "openai".to_string();
config.features.provider_model_ref = true;
config.defaults = Some(DefaultsConfig {
chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
fast: Some(bamboo_domain::ProviderModelRef::new(
"anthropic",
"claude-3-5-haiku",
)),
task_summary: None,
vision: None,
memory_background: None,
planning: None,
search: None,
code_review: None,
sub_agent: None,
subagent_models: Default::default(),
});
assert_eq!(
config.get_memory_background_model(),
Some("claude-3-5-haiku".to_string())
);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
let mut config = Config::default();
config.provider = "openai".to_string();
config.providers.openai = Some(OpenAIConfig {
api_key: "test".to_string(),
api_key_encrypted: None,
base_url: None,
model: Some("gpt-4o".to_string()),
fast_model: Some("legacy-gpt-4o-mini".to_string()),
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: Default::default(),
api_key_from_env: false,
});
config.features.provider_model_ref = false;
config.defaults = Some(DefaultsConfig {
chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
fast: Some(bamboo_domain::ProviderModelRef::new(
"anthropic",
"claude-3-5-haiku",
)),
task_summary: None,
vision: None,
memory_background: Some(bamboo_domain::ProviderModelRef::new(
"anthropic",
"claude-3-5-haiku",
)),
planning: None,
search: None,
code_review: None,
sub_agent: None,
subagent_models: Default::default(),
});
assert_eq!(
config.get_memory_background_model(),
Some("legacy-gpt-4o-mini".to_string())
);
}
}