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 = "default_true_memory_ledger_agenda",
alias = "memory_ledger_agenda_injection"
)]
pub ledger_agenda_injection: bool,
#[serde(default = "default_true_ledger_gardener_enabled")]
pub ledger_gardener_enabled: bool,
#[serde(default = "default_ledger_gardener_interval_secs")]
pub ledger_gardener_interval_secs: u64,
#[serde(default = "default_true_ledger_distillation_enabled")]
pub ledger_distillation_enabled: 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,
#[serde(default = "default_true_granularity_freshness_gardener_enabled")]
pub granularity_freshness_gardener_enabled: bool,
}
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(),
ledger_agenda_injection: default_true_memory_ledger_agenda(),
ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
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(),
granularity_freshness_gardener_enabled:
default_true_granularity_freshness_gardener_enabled(),
}
}
}
fn default_true_granularity_freshness_gardener_enabled() -> bool {
true
}
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_true_memory_ledger_agenda() -> bool {
true
}
fn default_true_ledger_gardener_enabled() -> bool {
true
}
fn default_ledger_gardener_interval_secs() -> u64 {
21_600
}
fn default_true_ledger_distillation_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, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct RunBudgetConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_total_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_calls: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_subagents: Option<u32>,
}
fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
match (config_default, request) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
impl RunBudgetConfig {
pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
let Some(over) = request_override else {
return *self;
};
Self {
max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
max_subagents: min_limit(self.max_subagents, over.max_subagents),
}
}
}
#[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(default, skip_serializing_if = "Option::is_none")]
pub claude_code_binary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_code_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_code_permission_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_code_inherit_user_config: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_code_forward_env: Option<Vec<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>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct McpRoleAllowlistEntry {
pub role: String,
#[serde(default)]
pub tools: Vec<String>,
}
#[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, Default, Serialize, Deserialize, PartialEq)]
pub struct DesktopChannelConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NtfyChannelConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_ntfy_base_url")]
pub base_url: String,
#[serde(default)]
pub topic: String,
#[serde(default, skip_serializing)]
pub token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_encrypted: Option<String>,
}
impl Default for NtfyChannelConfig {
fn default() -> Self {
Self {
enabled: false,
base_url: default_ntfy_base_url(),
topic: String::new(),
token: None,
token_encrypted: None,
}
}
}
fn default_ntfy_base_url() -> String {
"https://ntfy.sh".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BarkChannelConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_bark_base_url")]
pub base_url: String,
#[serde(default, skip_serializing)]
pub device_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device_key_encrypted: Option<String>,
}
impl Default for BarkChannelConfig {
fn default() -> Self {
Self {
enabled: false,
base_url: default_bark_base_url(),
device_key: None,
device_key_encrypted: None,
}
}
}
fn default_bark_base_url() -> String {
"https://api.day.app".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct NotificationsConfig {
#[serde(default)]
pub desktop: DesktopChannelConfig,
#[serde(default)]
pub ntfy: NtfyChannelConfig,
#[serde(default)]
pub bark: BarkChannelConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ConnectPlatformConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(rename = "type")]
pub platform_type: String,
#[serde(default, skip_serializing)]
pub token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_encrypted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_id: Option<String>,
#[serde(default, skip_serializing)]
pub app_secret: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_secret_encrypted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub admin_from: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ConnectConfig {
#[serde(default)]
pub platforms: Vec<ConnectPlatformConfig>,
}
fn connect_config_is_empty(config: &ConnectConfig) -> bool {
config.platforms.is_empty()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustedKey {
pub label: String,
pub algorithm: String,
pub public_key: String,
}
fn default_trusted_keys() -> Vec<TrustedKey> {
vec![
TrustedKey {
label: "nova (bigduu official)".to_string(),
algorithm: "ed25519".to_string(),
public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
.to_string(),
},
TrustedKey {
label: "magpie (bigduu official)".to_string(),
algorithm: "ed25519".to_string(),
public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
.to_string(),
},
]
}
fn default_trusted_hosts() -> Vec<String> {
vec!["github.com/bigduu/".to_string()]
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginTrustConfig {
#[serde(default = "default_trusted_hosts")]
pub trusted_hosts: Vec<String>,
#[serde(default = "default_trusted_keys")]
pub trusted_keys: Vec<TrustedKey>,
#[serde(default)]
pub enforcement: PluginTrustEnforcement,
}
impl Default for PluginTrustConfig {
fn default() -> Self {
Self {
trusted_hosts: default_trusted_hosts(),
trusted_keys: default_trusted_keys(),
enforcement: PluginTrustEnforcement::default(),
}
}
}
impl PluginTrustConfig {
pub fn is_host_trusted(&self, url: &str) -> bool {
is_host_trusted(url, &self.trusted_hosts)
}
pub fn enforcement_is_off(&self) -> bool {
matches!(self.enforcement, PluginTrustEnforcement::Off)
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PluginTrustEnforcement {
#[default]
Strict,
Off,
}
impl<'de> Deserialize<'de> for PluginTrustEnforcement {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Bool(bool),
Str(String),
}
match Repr::deserialize(deserializer)? {
Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
"strict" => Ok(PluginTrustEnforcement::Strict),
"off" => Ok(PluginTrustEnforcement::Off),
other => Err(serde::de::Error::custom(format!(
"invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
\"off\""
))),
},
}
}
}
struct TrustedHostEntry<'a> {
host: &'a str,
path_prefix: &'a str,
}
fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
match entry.find('/') {
Some(index) => TrustedHostEntry {
host: &entry[..index],
path_prefix: &entry[index..],
},
None => TrustedHostEntry {
host: entry,
path_prefix: "",
},
}
}
fn path_matches_prefix(path: &str, prefix: &str) -> bool {
if prefix.is_empty() || path == prefix {
return true;
}
if prefix.ends_with('/') {
return path.starts_with(prefix);
}
path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
}
pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
if parsed.scheme() != "https" {
return false;
}
let Some(host) = parsed.host_str() else {
return false;
};
let host = host.to_ascii_lowercase();
let path = parsed.path();
trusted_hosts.iter().any(|raw_entry| {
let entry = raw_entry.trim().to_ascii_lowercase();
let parsed_entry = parse_trusted_host_entry(&entry);
host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
})
}
#[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)]
pub run_budget: RunBudgetConfig,
#[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)]
pub notifications: NotificationsConfig,
#[serde(default, skip_serializing_if = "connect_config_is_empty")]
pub connect: ConnectConfig,
#[serde(default)]
pub plugin_trust: PluginTrustConfig,
#[serde(default, flatten)]
pub extra: BTreeMap<String, Value>,
#[serde(skip)]
pub recovery_status: Option<ConfigRecoveryStatus>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ConfigRecoverySource {
Salvaged { fields: Vec<String> },
Backup { generation: usize },
Defaults,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ConfigRecoveryStatus {
pub source: ConfigRecoverySource,
pub quarantine_path: Option<PathBuf>,
pub confirmed: bool,
}
#[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, skip_serializing_if = "Option::is_none")]
pub thinking_replay_always: Option<bool>,
#[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
);
let quarantine_path = quarantine_corrupt_config(&config_path);
let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
.map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
.or_else(|| {
Self::load_backup(&data_dir).map(|(cfg, generation)| {
(cfg, ConfigRecoverySource::Backup { generation })
})
})
.unwrap_or_else(|| {
tracing::warn!(
"Could not salvage and no usable config.json.bak; using defaults"
);
(Self::create_default(), ConfigRecoverySource::Defaults)
});
recovered.recovery_status = Some(ConfigRecoveryStatus {
source,
quarantine_path,
confirmed: false,
});
recovered
})
} 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.hydrate_notifications_from_encrypted();
config.merge_connect_config(&data_dir);
scrub_legacy_connect_from_config_backups(&data_dir);
config.hydrate_connect_platform_tokens_from_encrypted();
config.normalize_tool_settings();
config.normalize_skill_settings();
config.normalize_plugin_trust_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 merge_connect_config(&mut self, data_dir: &std::path::Path) {
let connect_path = data_dir.join("connect.json");
match std::fs::read_to_string(&connect_path) {
Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
Ok(connect) => {
let legacy_key_present = !connect_config_is_empty(&self.connect);
if legacy_key_present {
tracing::warn!(
"config.json still has a legacy `connect` key alongside \
connect.json; connect.json takes precedence — dropping the \
stale key from config.json now"
);
}
self.connect = connect;
if legacy_key_present {
strip_legacy_connect_key_from_config_json(data_dir);
}
}
Err(e) => {
tracing::error!(
"Failed to parse {:?} ({}); continuing with an empty (inert) \
connect config instead of falling back to a legacy config.json copy",
connect_path,
e
);
quarantine_corrupt_connect(&connect_path);
self.connect = ConnectConfig::default();
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if !connect_config_is_empty(&self.connect) {
tracing::info!(
"Migrating legacy `connect` config from config.json to a \
standalone connect.json"
);
strip_legacy_connect_key_from_config_json(data_dir);
if let Err(e) = save_connect_config(&self.connect, data_dir) {
tracing::error!("Failed to write connect.json during migration: {}", e);
}
}
}
Err(e) => {
tracing::error!(
"Failed to read {:?} ({}); continuing with an empty (inert) connect config",
connect_path,
e
);
self.connect = ConnectConfig::default();
}
}
}
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.hydrate_notifications_from_encrypted();
config.hydrate_connect_platform_tokens_from_encrypted();
config.normalize_tool_settings();
config.normalize_skill_settings();
config
})
}
fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
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, gen));
}
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, Vec<String>)> {
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, _generation)| 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()
.map(|config| (config, salvaged))
}
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 normalize_plugin_trust_settings(&mut self) {
self.plugin_trust.trusted_hosts = self
.plugin_trust
.trusted_hosts
.iter()
.map(|entry| entry.trim().to_ascii_lowercase())
.filter(|entry| !entry.is_empty())
.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(),
run_budget: RunBudgetConfig::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(),
notifications: NotificationsConfig::default(),
connect: ConnectConfig::default(),
plugin_trust: PluginTrustConfig::default(),
extra: BTreeMap::new(),
recovery_status: None,
}
}
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 recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
self.recovery_status.as_ref()
}
pub fn confirm_recovery(&mut self) {
if let Some(status) = self.recovery_status.as_mut() {
status.confirmed = true;
}
}
pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
if self.recovery_status.is_none() {
anyhow::bail!("No pending config-corruption recovery to confirm");
}
self.confirm_recovery();
self.save_to_dir(data_dir)?;
self.recovery_status = None;
Ok(())
}
pub fn assign_connect_platform_ids(&mut self) {
for platform in &mut self.connect.platforms {
if platform.id.is_none() {
platform.id = Some(uuid::Uuid::new_v4().to_string());
}
}
}
pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
anyhow::bail!(
"refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
has not been confirmed; the corrupt original is preserved at {:?}. Call \
Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
status.source,
status.quarantine_path,
);
}
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_encrypted_secrets()?;
to_save.sanitize_env_vars_for_disk();
to_save.sanitize_cluster_fabric_for_disk();
to_save.assign_connect_platform_ids();
to_save.normalize_tool_settings();
to_save.normalize_skill_settings();
let mut config_value =
serde_json::to_value(&to_save).context("Failed to serialize config to JSON")?;
if let Some(obj) = config_value.as_object_mut() {
obj.remove("connect");
}
let content = serde_json::to_string_pretty(&config_value)
.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))?;
save_connect_config(&to_save.connect, &data_dir)?;
Ok(())
}
}
fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
let path = data_dir.join("connect.json");
if connect_config_is_empty(connect) && !path.exists() {
return Ok(());
}
if path.exists() {
let backup = path.with_extension("json.bak");
if let Err(e) = std::fs::copy(&path, &backup) {
tracing::warn!("Failed to back up connect.json before save: {}", e);
}
}
let content = serde_json::to_string_pretty(connect)
.context("Failed to serialize connect config to JSON")?;
write_atomic(&path, content.as_bytes())
.with_context(|| format!("Failed to write connect config file: {:?}", path))?;
Ok(())
}
fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
let config_path = data_dir.join("config.json");
let content = match std::fs::read_to_string(&config_path) {
Ok(content) => content,
Err(e) => {
tracing::error!(
"Failed to read config.json to strip legacy `connect` key: {}",
e
);
return;
}
};
let mut value: serde_json::Value = match serde_json::from_str(&content) {
Ok(value) => value,
Err(e) => {
tracing::error!(
"Failed to parse config.json to strip legacy `connect` key: {}",
e
);
return;
}
};
let Some(obj) = value.as_object_mut() else {
return;
};
if obj.remove("connect").is_none() {
return;
}
let rewritten = match serde_json::to_string_pretty(&value) {
Ok(rewritten) => rewritten,
Err(e) => {
tracing::error!(
"Failed to serialize config.json after stripping legacy `connect` key: {}",
e
);
return;
}
};
if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
tracing::error!(
"Failed to write config.json after stripping legacy `connect` key: {}",
e
);
}
}
fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
let config_path = data_dir.join("config.json");
for gen in 0..BAK_GENERATIONS {
let backup = backup_path_for(&config_path, gen);
let content = match std::fs::read_to_string(&backup) {
Ok(content) => content,
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
"Failed to read {:?} while scanning for legacy connect data ({}); \
leaving it untouched",
backup,
e
);
}
continue;
}
};
let mut value: serde_json::Value = match serde_json::from_str(&content) {
Ok(value) => value,
Err(e) => {
tracing::warn!(
"Skipping unparsable backup {:?} while scanning for legacy connect data \
({}); left untouched (never deleted)",
backup,
e
);
continue;
}
};
let Some(obj) = value.as_object_mut() else {
continue;
};
if obj.remove("connect").is_none() {
continue;
}
let rewritten = match serde_json::to_string_pretty(&value) {
Ok(rewritten) => rewritten,
Err(e) => {
tracing::error!(
"Failed to serialize {:?} after stripping legacy connect data: {}",
backup,
e
);
continue;
}
};
match write_atomic(&backup, rewritten.as_bytes()) {
Ok(()) => tracing::info!(
"Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
backup
),
Err(e) => tracing::error!(
"Failed to write {:?} after stripping legacy connect data: {}",
backup,
e
),
}
}
}
fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
let backup = connect_path.with_extension("json.bak");
match std::fs::rename(connect_path, &backup) {
Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
Err(e) => {
tracing::warn!(
"Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
backup,
e
);
if let Err(e) = std::fs::copy(connect_path, &backup) {
tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
return;
}
if let Err(e) = std::fs::remove_file(connect_path) {
tracing::error!(
"Quarantined corrupt connect.json to {:?} but failed to remove the \
original {:?}: {}",
backup,
connect_path,
e
);
}
}
}
}
const QUARANTINE_KEEP: usize = 5;
fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
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;
}
let result = match std::fs::copy(config_path, &quarantine) {
Ok(_) => {
tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
Some(quarantine)
}
Err(e) => {
tracing::error!("Failed to quarantine corrupt config.json: {}", e);
None
}
};
prune_quarantine_files(config_path, QUARANTINE_KEEP);
result
}
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)?;
static NEXT_TMP_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let unique = NEXT_TMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
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(), unique);
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 run_budget_config_merge_is_tighten_only_per_field() {
let config_default = RunBudgetConfig {
max_total_tokens: Some(100_000),
max_tool_calls: Some(500),
max_subagents: Some(10),
};
assert_eq!(
config_default.merged_with_override(None),
config_default,
"no override falls back to the config default entirely"
);
let tighten_one = RunBudgetConfig {
max_total_tokens: Some(5_000),
max_tool_calls: None,
max_subagents: None,
};
let merged = config_default.merged_with_override(Some(&tighten_one));
assert_eq!(merged.max_total_tokens, Some(5_000));
assert_eq!(merged.max_tool_calls, Some(500));
assert_eq!(merged.max_subagents, Some(10));
let loosen_attempt = RunBudgetConfig {
max_total_tokens: Some(999_999_999),
max_tool_calls: Some(10_000),
max_subagents: Some(1_000),
};
assert_eq!(
config_default.merged_with_override(Some(&loosen_attempt)),
config_default,
"looser per-request values must be clamped to the config ceiling"
);
let empty_override = RunBudgetConfig::default();
assert_eq!(
config_default.merged_with_override(Some(&empty_override)),
config_default,
"an all-absent override body keeps every configured ceiling"
);
let unlimited_default = RunBudgetConfig::default();
let merged = unlimited_default.merged_with_override(Some(&tighten_one));
assert_eq!(merged.max_total_tokens, Some(5_000));
assert_eq!(merged.max_tool_calls, None);
assert_eq!(merged.max_subagents, None);
}
#[test]
fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
assert_eq!(RunBudgetConfig::default().max_subagents, None);
let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
assert_eq!(cfg.max_total_tokens, Some(250_000));
assert_eq!(
cfg.max_tool_calls, None,
"absent field defaults to unlimited"
);
assert_eq!(cfg.max_subagents, Some(3));
let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
assert_eq!(empty, "{}");
}
#[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 subagents_config_without_mcp_role_allowlist_deserializes_empty() {
let json = r#"{ "max_concurrent": 4 }"#;
let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
assert!(cfg.mcp_role_allowlist.is_empty());
let back = serde_json::to_string(&cfg).unwrap();
assert!(
!back.contains("mcp_role_allowlist"),
"empty vec is skipped: {back}"
);
}
#[test]
fn mcp_role_allowlist_entry_round_trips() {
let json = r#"{
"mcp_role_allowlist": [
{ "role": "researcher", "tools": ["fetch_url"] },
{ "role": "sandboxed", "tools": [] }
]
}"#;
let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
assert_eq!(cfg.mcp_role_allowlist.len(), 2);
assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
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");
}
#[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 {
static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
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,
unique
));
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 recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
let temp = TempHome::new();
std::fs::write(
temp.path.join("config.json.bak"),
serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
)
.unwrap();
let corrupt_bytes = "{ not valid json ";
std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
let status = config
.recovery_status()
.expect("a corrupt load must set a pending recovery status");
assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
assert_eq!(
status.source,
ConfigRecoverySource::Backup { generation: 0 },
"recovered from generation-0 (.bak)"
);
let quarantine_path = status
.quarantine_path
.as_ref()
.expect("quarantine copy should have succeeded");
assert_eq!(
std::fs::read_to_string(quarantine_path).unwrap(),
corrupt_bytes,
"the quarantine copy preserves the corrupt original BYTE FOR BYTE"
);
assert_eq!(
std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
corrupt_bytes,
"the original config.json itself is untouched by the load (only copied, not moved)"
);
}
#[test]
fn recovery_status_set_from_salvage_lists_recovered_fields() {
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()));
let status = config.recovery_status().expect("pending recovery");
assert!(!status.confirmed);
match &status.source {
ConfigRecoverySource::Salvaged { fields } => {
assert!(
fields.iter().any(|f| f == "http_proxy"),
"salvaged fields should list the recovered key: {fields:?}"
);
}
other => panic!("expected Salvaged source, got {other:?}"),
}
}
#[test]
fn recovery_status_set_from_defaults_when_nothing_salvageable() {
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()));
let status = config.recovery_status().expect("pending recovery");
assert!(!status.confirmed);
assert_eq!(status.source, ConfigRecoverySource::Defaults);
}
#[test]
fn clean_load_never_sets_recovery_status() {
let temp = TempHome::new();
temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(
config.recovery_status().is_none(),
"a config.json that parses cleanly must never carry a pending recovery status"
);
}
#[test]
fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
let temp = TempHome::new();
let corrupt_bytes = "}}} broken";
std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(config.recovery_status().is_some());
let err = config
.save_to_dir(temp.path.clone())
.expect_err("save must refuse while recovery is unconfirmed");
assert!(
err.to_string().contains("recovered from corruption")
|| err.to_string().contains("confirm"),
"error should explain the refused overwrite: {err}"
);
assert_eq!(
std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
corrupt_bytes,
"a refused save must leave the corrupt original untouched"
);
}
#[test]
fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
let temp = TempHome::new();
let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
std::fs::write(temp.path.join("config.json"), truncated).unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
let status = config.recovery_status().expect("pending recovery");
let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
assert_eq!(
std::fs::read_to_string(quarantine_path).unwrap(),
truncated,
"truncated original preserved byte for byte in quarantine"
);
let err = config.save_to_dir(temp.path.clone());
assert!(err.is_err(), "unconfirmed recovery must refuse to save");
assert_eq!(
std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
truncated,
"the half-written original stays exactly as it was after a refused save"
);
}
#[test]
fn confirm_recovery_allows_the_next_save() {
let temp = TempHome::new();
std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(config.recovery_status().is_some());
config.confirm_recovery();
assert!(
config.recovery_status().is_some_and(|s| s.confirmed),
"confirm_recovery flips the flag but keeps the status around"
);
config
.save_to_dir(temp.path.clone())
.expect("save must succeed once the recovery is confirmed");
}
#[test]
fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
let temp = TempHome::new();
std::fs::write(
temp.path.join("config.json"),
r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
)
.unwrap();
let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(config.recovery_status().is_some());
let quarantine_path = config
.recovery_status()
.unwrap()
.quarantine_path
.clone()
.unwrap();
config
.confirm_recovery_and_save_to_dir(temp.path.clone())
.expect("confirm+save should succeed");
assert!(
config.recovery_status().is_none(),
"the pending flag is cleared once the recovery is confirmed and persisted"
);
let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
assert!(
on_disk.contains("http://recovered"),
"config.json now holds the recovered (salvaged) state"
);
assert!(
quarantine_path.exists(),
"the quarantined original survives confirmation — it's never deleted"
);
}
#[test]
fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
let temp = TempHome::new();
let mut config = Config::create_default();
let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
assert!(
err.is_err(),
"confirming a recovery that was never pending must error, not silently succeed"
);
}
fn connect_platform_with_encrypted(
platform_type: &str,
token_encrypted: &str,
) -> ConnectPlatformConfig {
ConnectPlatformConfig {
id: None,
platform_type: platform_type.to_string(),
token: None,
token_encrypted: Some(token_encrypted.to_string()),
app_id: None,
app_secret: None,
app_secret_encrypted: None,
domain: None,
allow_from: vec!["user-1".to_string()],
admin_from: Vec::new(),
}
}
fn connect_json_path(temp: &TempHome) -> PathBuf {
temp.path.join("connect.json")
}
#[test]
fn save_splits_connect_into_sibling_connect_json() {
let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
let temp = TempHome::new();
let mut config = Config::create_default();
config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
config.connect.platforms[0].token = Some("plain-bot-token".to_string());
config
.save_to_dir(temp.path.clone())
.expect("save succeeds");
let config_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
.unwrap();
assert!(
config_json.get("connect").is_none(),
"config.json must not carry the `connect` key after a save"
);
let connect_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
.unwrap();
assert_eq!(connect_json["platforms"][0]["type"], "telegram");
assert!(
connect_json["platforms"][0]["token_encrypted"]
.as_str()
.is_some_and(|v| !v.is_empty()),
"the token is persisted in its encrypted form in connect.json"
);
assert!(
connect_json["platforms"][0].get("token").is_none(),
"the plaintext token is never persisted (skip_serializing)"
);
}
#[test]
fn save_assigns_a_missing_connect_platform_id() {
let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
let temp = TempHome::new();
let mut config = Config::create_default();
config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
assert!(
config.connect.platforms[0].id.is_none(),
"precondition: the entry starts without an id"
);
config
.save_to_dir(temp.path.clone())
.expect("save succeeds");
let connect_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
.unwrap();
let persisted_id = connect_json["platforms"][0]["id"]
.as_str()
.expect("save_to_dir must backfill a missing id onto the persisted entry");
assert!(!persisted_id.is_empty());
let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(
reloaded.connect.platforms[0].id.as_deref(),
Some(persisted_id),
"the assigned id round-trips through a reload"
);
}
#[test]
fn save_never_reassigns_an_existing_connect_platform_id() {
let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
let temp = TempHome::new();
let mut config = Config::create_default();
let mut platform = connect_platform_with_encrypted("telegram", "cipher");
platform.id = Some("stable-id-123".to_string());
config.connect.platforms = vec![platform];
config
.save_to_dir(temp.path.clone())
.expect("first save succeeds");
config
.save_to_dir(temp.path.clone())
.expect("second save succeeds");
let connect_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
.unwrap();
assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
}
#[test]
fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
let temp = TempHome::new();
let mut config = Config::create_default();
config.connect.platforms = vec![
connect_platform_with_encrypted("telegram", "cipher-a"),
connect_platform_with_encrypted("telegram", "cipher-b"),
];
config
.save_to_dir(temp.path.clone())
.expect("save succeeds");
let connect_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
.unwrap();
let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
assert_ne!(
id_a, id_b,
"two entries sharing platform_type must still get distinct ids"
);
}
#[test]
fn load_never_assigns_or_persists_an_id_by_itself() {
let temp = TempHome::new();
std::fs::write(
connect_json_path(&temp),
serde_json::json!({
"platforms": [
{ "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
]
})
.to_string(),
)
.unwrap();
let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(
config.connect.platforms[0].id.is_none(),
"load alone must not backfill an id in memory"
);
let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
assert_eq!(
connect_json_before, connect_json_after,
"load must never rewrite connect.json on disk just to backfill an id (#493)"
);
}
#[test]
fn load_merges_connect_json_into_config() {
let temp = TempHome::new();
std::fs::write(
connect_json_path(&temp),
serde_json::json!({
"platforms": [
{ "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
]
})
.to_string(),
)
.unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(config.connect.platforms.len(), 1);
assert_eq!(config.connect.platforms[0].platform_type, "telegram");
assert_eq!(
config.connect.platforms[0].token_encrypted.as_deref(),
Some("cipher-abc")
);
}
#[test]
fn load_without_connect_json_yields_empty_inert_connect_config() {
let temp = TempHome::new();
temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(
config.connect.platforms.is_empty(),
"no connect.json and no legacy key -> empty/inert connect config"
);
assert!(
!connect_json_path(&temp).exists(),
"load must not create connect.json when there is nothing to migrate"
);
}
#[test]
fn migration_adopts_legacy_connect_key_and_writes_both_files() {
let temp = TempHome::new();
temp.set_config_json(
&serde_json::json!({
"http_proxy": "http://keep-me",
"connect": {
"platforms": [
{ "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
]
}
})
.to_string(),
);
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(config.connect.platforms.len(), 1);
assert_eq!(
config.connect.platforms[0].token_encrypted.as_deref(),
Some("legacy-cipher")
);
assert_eq!(config.http_proxy, "http://keep-me");
assert!(
connect_json_path(&temp).exists(),
"migration proactively creates connect.json"
);
let connect_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
.unwrap();
assert_eq!(
connect_json["platforms"][0]["token_encrypted"], "legacy-cipher",
"the encrypted value is preserved (encrypted form intact) by the migration"
);
let config_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
.unwrap();
assert!(
config_json.get("connect").is_none(),
"config.json is rewritten without the legacy `connect` key"
);
}
#[test]
fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
let temp = TempHome::new();
let original_api_key_encrypted =
crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
temp.set_config_json(
&serde_json::json!({
"providers": {
"openai": {
"api_key_encrypted": original_api_key_encrypted,
}
},
"connect": {
"platforms": [
{ "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
]
}
})
.to_string(),
);
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
assert!(
!temp.path.join("config.json.bak").exists(),
"a read-only load migrating a legacy `connect` key must not rotate \
config.json backups"
);
let config_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
.unwrap();
assert_eq!(
config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
"an unrelated secret's ciphertext must not be touched by the connect \
migration's narrow write"
);
assert!(
config_json.get("connect").is_none(),
"config.json is still rewritten without the legacy `connect` key"
);
}
#[test]
fn both_files_present_connect_json_wins() {
let temp = TempHome::new();
temp.set_config_json(
&serde_json::json!({
"connect": {
"platforms": [
{ "type": "telegram", "token_encrypted": "stale-config-json-cipher" }
]
}
})
.to_string(),
);
std::fs::write(
connect_json_path(&temp),
serde_json::json!({
"platforms": [
{ "type": "telegram", "token_encrypted": "authoritative-cipher" }
]
})
.to_string(),
)
.unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(
config.connect.platforms[0].token_encrypted.as_deref(),
Some("authoritative-cipher"),
"connect.json wins over a stale legacy config.json key"
);
}
#[test]
fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
let temp = TempHome::new();
temp.set_config_json(
&serde_json::json!({
"http_proxy": "http://keep-me",
"connect": {
"platforms": [
{ "type": "telegram", "token_encrypted": "stale-config-json-cipher" }
]
}
})
.to_string(),
);
std::fs::write(
connect_json_path(&temp),
serde_json::json!({
"platforms": [
{ "type": "telegram", "token_encrypted": "authoritative-cipher" }
]
})
.to_string(),
)
.unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(
config.connect.platforms[0].token_encrypted.as_deref(),
Some("authoritative-cipher")
);
assert_eq!(config.http_proxy, "http://keep-me");
let config_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
.unwrap();
assert!(
config_json.get("connect").is_none(),
"the stale legacy `connect` key must be stripped from config.json \
immediately on load, not left for the next natural save"
);
}
#[test]
fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
let temp = TempHome::new();
temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
std::fs::write(
temp.path.join("config.json.bak"),
serde_json::json!({
"http_proxy": "http://old",
"connect": {
"platforms": [
{ "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
]
}
})
.to_string(),
)
.unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(config.connect.platforms.is_empty());
let bak: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
)
.unwrap();
assert!(
bak.get("connect").is_none(),
"the legacy `connect` key must be stripped from the tainted .bak generation"
);
assert_eq!(
bak["http_proxy"], "http://old",
"every other key in the .bak generation survives the scrub byte-for-byte in content"
);
}
#[test]
fn scrub_reaches_all_rotated_generations() {
let temp = TempHome::new();
temp.set_config_json(&serde_json::json!({}).to_string());
for (gen_suffix, cipher) in [
("config.json.bak", "cipher-gen0"),
("config.json.bak.1", "cipher-gen1"),
("config.json.bak.2", "cipher-gen2"),
] {
std::fs::write(
temp.path.join(gen_suffix),
serde_json::json!({
"connect": {
"platforms": [
{ "type": "telegram", "token_encrypted": cipher }
]
}
})
.to_string(),
)
.unwrap();
}
let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
.unwrap();
assert!(
value.get("connect").is_none(),
"{gen_suffix} must have its legacy `connect` key stripped"
);
}
}
#[test]
fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
let temp = TempHome::new();
temp.set_config_json(&serde_json::json!({}).to_string());
let bak_path = temp.path.join("config.json.bak");
std::fs::write(
&bak_path,
serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
)
.unwrap();
let before_bytes = std::fs::read(&bak_path).unwrap();
let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
let after_bytes = std::fs::read(&bak_path).unwrap();
let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
assert_eq!(
before_bytes, after_bytes,
"a .bak generation without a legacy `connect` key must not be rewritten at all"
);
assert_eq!(
before_mtime, after_mtime,
"no write means no mtime change either"
);
}
#[test]
fn scrub_skips_unparsable_backup_without_deleting_it() {
let temp = TempHome::new();
temp.set_config_json(&serde_json::json!({}).to_string());
std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
assert_eq!(
content, "{ not valid json",
"an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
);
}
#[test]
fn scrub_tolerates_missing_generations() {
let temp = TempHome::new();
temp.set_config_json(&serde_json::json!({}).to_string());
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(config.connect.platforms.is_empty());
assert!(!temp.path.join("config.json.bak").exists());
}
#[test]
fn scrub_does_not_break_backup_rotation() {
let temp = TempHome::new();
std::fs::write(
temp.path.join("config.json"),
serde_json::json!({
"http_proxy": "http://proxy-v1",
"connect": {
"platforms": [
{ "type": "telegram", "token_encrypted": "legacy-cipher" }
]
}
})
.to_string(),
)
.unwrap();
std::fs::write(
temp.path.join("config.json.bak"),
serde_json::json!({
"http_proxy": "http://proxy-v0",
"connect": {
"platforms": [
{ "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
]
}
})
.to_string(),
)
.unwrap();
let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
let bak: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
)
.unwrap();
assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
config.http_proxy = "http://proxy-v2".to_string();
config.save_to_dir(temp.path.clone()).unwrap();
let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
assert!(
new_bak.contains("proxy-v1"),
".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
);
let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
assert!(
new_bak1.contains("proxy-v0"),
".bak.1 holds the scrubbed older generation after rotation"
);
assert!(
!new_bak1.contains("legacy-bak-cipher"),
"the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
stripped secret"
);
}
#[test]
fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
let temp = TempHome::new();
temp.set_config_json("{}");
std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(
config.connect.platforms.is_empty(),
"corrupt connect.json fails SAFE to an empty/inert connect config"
);
let backup = connect_json_path(&temp).with_extension("json.bak");
assert!(
backup.exists(),
"the corrupt connect.json is quarantined to connect.json.bak"
);
assert!(
std::fs::read_to_string(backup)
.unwrap()
.contains("not valid json"),
"the quarantined copy holds the bad content"
);
assert!(
!connect_json_path(&temp).exists(),
"quarantine must MOVE the corrupt connect.json (not copy it) — no \
connect.json should remain after quarantine"
);
}
#[test]
fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
let temp = TempHome::new();
temp.set_config_json(
&serde_json::json!({
"connect": {
"platforms": [
{ "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
]
}
})
.to_string(),
);
std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert!(
config.connect.platforms.is_empty(),
"corrupt connect.json must not fall back to the legacy config.json copy"
);
}
#[test]
fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
let temp = TempHome::new();
let config = Config::create_default();
assert!(config.connect.platforms.is_empty());
config
.save_to_dir(temp.path.clone())
.expect("save succeeds");
assert!(
!connect_json_path(&temp).exists(),
"an empty connect config with no pre-existing file must not create one"
);
}
#[test]
fn connect_json_backed_up_before_overwrite() {
let temp = TempHome::new();
std::fs::write(
connect_json_path(&temp),
serde_json::json!({
"platforms": [
{ "type": "telegram", "token_encrypted": "old-cipher" }
]
})
.to_string(),
)
.unwrap();
let mut config = Config::create_default();
config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
config
.save_to_dir(temp.path.clone())
.expect("save succeeds");
let backup = connect_json_path(&temp).with_extension("json.bak");
assert!(
std::fs::read_to_string(backup)
.unwrap()
.contains("old-cipher"),
"the previous connect.json is preserved as connect.json.bak before the overwrite"
);
let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
assert!(current.contains("new-cipher"));
}
#[test]
fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
let temp = TempHome::new();
let mut config = Config::create_default();
config.connect.platforms = vec![ConnectPlatformConfig {
id: None,
platform_type: "feishu".to_string(),
token: None,
token_encrypted: None,
app_id: Some("cli_real_app_id".to_string()),
app_secret: Some("plain-app-secret".to_string()),
app_secret_encrypted: None,
domain: Some("lark".to_string()),
allow_from: vec!["ou_1".to_string()],
admin_from: Vec::new(),
}];
config
.save_to_dir(temp.path.clone())
.expect("save succeeds");
let connect_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
.unwrap();
assert_eq!(connect_json["platforms"][0]["type"], "feishu");
assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
assert_eq!(connect_json["platforms"][0]["domain"], "lark");
assert!(
connect_json["platforms"][0]["app_secret_encrypted"]
.as_str()
.is_some_and(|v| !v.is_empty()),
"app_secret is persisted in its encrypted form in connect.json"
);
assert!(
connect_json["platforms"][0].get("app_secret").is_none(),
"the plaintext app_secret is never persisted (skip_serializing)"
);
}
#[test]
fn load_hydrates_feishu_app_secret_from_encrypted() {
let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
let temp = TempHome::new();
let mut config = Config::create_default();
config.connect.platforms = vec![ConnectPlatformConfig {
id: None,
platform_type: "feishu".to_string(),
token: None,
token_encrypted: None,
app_id: Some("cli_real_app_id".to_string()),
app_secret: Some("plain-app-secret".to_string()),
app_secret_encrypted: None,
domain: Some("lark".to_string()),
allow_from: vec!["ou_1".to_string()],
admin_from: Vec::new(),
}];
config
.save_to_dir(temp.path.clone())
.expect("save succeeds");
let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(reloaded.connect.platforms.len(), 1);
assert_eq!(
reloaded.connect.platforms[0].app_secret.as_deref(),
Some("plain-app-secret"),
"reload hydrates app_secret from app_secret_encrypted"
);
assert_eq!(
reloaded.connect.platforms[0].app_id.as_deref(),
Some("cli_real_app_id")
);
assert_eq!(
reloaded.connect.platforms[0].domain.as_deref(),
Some("lark")
);
}
#[test]
fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
let temp = TempHome::new();
std::fs::write(
connect_json_path(&temp),
serde_json::json!({
"platforms": [
{ "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
]
})
.to_string(),
)
.unwrap();
let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
assert_eq!(config.connect.platforms.len(), 1);
assert_eq!(config.connect.platforms[0].platform_type, "telegram");
assert_eq!(
config.connect.platforms[0].token_encrypted.as_deref(),
Some("legacy-cipher")
);
assert_eq!(
config.connect.platforms[0].app_id, None,
"a legacy entry with no Feishu fields deserializes them as None"
);
assert_eq!(config.connect.platforms[0].app_secret, None);
assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
assert_eq!(config.connect.platforms[0].domain, None);
}
#[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 refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
let mut config = Config::default();
let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
"provider_type": "openai",
"api_key": "sk-instance-live",
}))
.expect("valid instance");
config
.provider_instances
.insert("work".to_string(), instance);
config.refresh_encrypted_secrets().expect("refresh");
assert!(
config.provider_instances["work"]
.api_key_encrypted
.is_some(),
"live config must hold ciphertext after refresh"
);
let value = serde_json::to_value(&config).expect("serialize");
let mut back: Config = serde_json::from_value(value).expect("deserialize");
assert!(
back.provider_instances["work"].api_key.is_empty(),
"plaintext is skip_serializing"
);
back.hydrate_provider_instance_api_keys_from_encrypted();
assert_eq!(
back.provider_instances["work"].api_key, "sk-instance-live",
"key must be recoverable from the round-tripped ciphertext"
);
}
#[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,
ledger_agenda_injection: false,
ledger_gardener_enabled: false,
ledger_gardener_interval_secs: 7_200,
ledger_distillation_enabled: 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,
granularity_freshness_gardener_enabled: false,
}),
..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);
assert!(!memory.granularity_freshness_gardener_enabled);
}
#[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 _lock = crate::test_support::env_cache_lock_acquire();
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()
};
config.publish_env_vars();
assert_eq!(
Config::current_env_vars()
.get("TEST_PUBLISH")
.map(String::as_str),
Some("pub_value")
);
}
#[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 notifications_config_defaults_when_key_missing() {
let config: Config = serde_json::from_str("{}").expect("empty object parses");
assert_eq!(config.notifications, NotificationsConfig::default());
assert_eq!(config.notifications.desktop.enabled, None);
assert!(!config.notifications.ntfy.enabled);
assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
assert_eq!(config.notifications.ntfy.token, None);
assert!(!config.notifications.bark.enabled);
assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
assert_eq!(config.notifications.bark.device_key, None);
}
#[test]
fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
let mut config = Config::default();
config.notifications.ntfy = NtfyChannelConfig {
enabled: true,
base_url: "https://ntfy.sh".to_string(),
topic: "bamboo-alerts".to_string(),
token: Some("tk_super_secret".to_string()),
token_encrypted: None,
};
config.refresh_notifications_encrypted().unwrap();
assert!(config.notifications.ntfy.token_encrypted.is_some());
assert_ne!(
config.notifications.ntfy.token_encrypted.as_deref(),
Some("tk_super_secret")
);
let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
assert!(
!json.contains("tk_super_secret"),
"plaintext token must never be serialized"
);
assert!(json.contains("token_encrypted"));
config.notifications.ntfy.token = None;
config.hydrate_notifications_from_encrypted();
assert_eq!(
config.notifications.ntfy.token.as_deref(),
Some("tk_super_secret")
);
}
#[test]
fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
let mut config = Config::default();
config.notifications.bark = BarkChannelConfig {
enabled: true,
base_url: "https://api.day.app".to_string(),
device_key: Some("dk_super_secret".to_string()),
device_key_encrypted: None,
};
config.refresh_notifications_encrypted().unwrap();
assert!(config.notifications.bark.device_key_encrypted.is_some());
assert_ne!(
config.notifications.bark.device_key_encrypted.as_deref(),
Some("dk_super_secret")
);
let json = serde_json::to_string(&config.notifications.bark).unwrap();
assert!(
!json.contains("dk_super_secret"),
"plaintext device key must never be serialized"
);
assert!(json.contains("device_key_encrypted"));
config.notifications.bark.device_key = None;
config.hydrate_notifications_from_encrypted();
assert_eq!(
config.notifications.bark.device_key.as_deref(),
Some("dk_super_secret")
);
}
#[test]
fn notification_secrets_empty_refresh_preserves_ciphertext() {
let mut config = Config::default();
config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
config.refresh_notifications_encrypted().unwrap();
assert_eq!(
config.notifications.ntfy.token_encrypted.as_deref(),
Some("existing-ntfy-cipher")
);
assert_eq!(
config.notifications.bark.device_key_encrypted.as_deref(),
Some("existing-bark-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())
);
}
#[test]
fn is_host_trusted_requires_https_scheme() {
let hosts = vec!["github.com/bigduu/".to_string()];
assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
}
#[test]
fn is_host_trusted_is_case_insensitive_on_both_sides() {
let hosts = vec!["GitHub.com/BigDuu/".to_string()];
assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
let hosts = vec!["github.com/bigduu/".to_string()];
assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
}
#[test]
fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
let hosts = vec!["trusted.example.com".to_string()];
assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
assert!(!is_host_trusted(
"https://trusted.example.com.evil.com/x",
&hosts
));
assert!(!is_host_trusted(
"https://trusted.example.comevil.com/x",
&hosts
));
}
#[test]
fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
let hosts = vec!["github.com/bigduu/".to_string()];
assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
}
#[test]
fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
let hosts = vec!["example.com".to_string()];
assert!(is_host_trusted("https://example.com/", &hosts));
assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
assert!(!is_host_trusted("https://evil-example.com/", &hosts));
}
#[test]
fn is_host_trusted_uses_the_real_host_not_userinfo() {
let hosts = vec!["github.com/bigduu/".to_string()];
assert!(is_host_trusted(
"https://someuser@github.com/bigduu/x",
&hosts
));
assert!(!is_host_trusted(
"https://github.com@evil.com/bigduu/",
&hosts
));
}
#[test]
fn is_host_trusted_ignores_an_explicit_port() {
let hosts = vec!["github.com/bigduu/".to_string()];
assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
}
#[test]
fn is_host_trusted_malformed_url_is_refused_without_panicking() {
let hosts = vec!["github.com/bigduu/".to_string()];
assert!(!is_host_trusted("not a url at all", &hosts));
assert!(!is_host_trusted("", &hosts));
assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
#[test]
fn is_host_trusted_normalizes_dot_segments_before_matching() {
let hosts = vec!["github.com/bigduu/".to_string()];
assert!(!is_host_trusted(
"https://github.com/bigduu/../evil/x",
&hosts
));
assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
}
#[test]
fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
let mut config = Config::default();
config.plugin_trust.trusted_hosts = vec![
" GitHub.com/BigDuu/ ".to_string(),
"".to_string(),
" ".to_string(),
"Example.COM".to_string(),
];
config.normalize_plugin_trust_settings();
assert_eq!(
config.plugin_trust.trusted_hosts,
vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
);
}
#[test]
fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
let config = Config::default();
assert_eq!(
config.plugin_trust.enforcement,
PluginTrustEnforcement::Strict
);
assert!(!config.plugin_trust.enforcement_is_off());
let json = serde_json::json!({
"trusted_hosts": ["example.com"],
"trusted_keys": [],
});
let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
}
#[test]
fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
for raw in ["off", "OFF", "Off", " off "] {
let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
"enforcement": raw,
}))
.unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
assert!(trust.enforcement_is_off());
}
for raw in ["strict", "STRICT", " Strict "] {
let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
"enforcement": raw,
}))
.unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
}
let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
"enforcement": "nonsense",
}))
.expect_err("an unrecognized string must be rejected, not silently default");
assert!(err.to_string().contains("nonsense"));
}
#[test]
fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
let trust: PluginTrustConfig =
serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
let trust: PluginTrustConfig =
serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
}
#[test]
fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
let trust = PluginTrustConfig {
enforcement: PluginTrustEnforcement::Off,
..PluginTrustConfig::default()
};
let json = serde_json::to_value(&trust).unwrap();
assert_eq!(json["enforcement"], "off");
let trust = PluginTrustConfig {
enforcement: PluginTrustEnforcement::Strict,
..PluginTrustConfig::default()
};
let json = serde_json::to_value(&trust).unwrap();
assert_eq!(json["enforcement"], "strict");
}
#[test]
fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
let mut config = Config::default();
config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
config.normalize_plugin_trust_settings();
assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
}
#[test]
fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
"/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
)));
assert_eq!(
config.plugin_trust.enforcement,
PluginTrustEnforcement::Strict
);
let outcome = crate::dot_path::apply_dot_path_set(
&config,
"plugin_trust.enforcement",
crate::dot_path::parse_cli_value("off"),
)
.expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
assert_eq!(
outcome.config.plugin_trust.enforcement,
PluginTrustEnforcement::Off
);
let outcome = crate::dot_path::apply_dot_path_set(
&outcome.config,
"plugin_trust.enforcement",
crate::dot_path::parse_cli_value("strict"),
)
.expect("setting it back to strict should also round-trip");
assert_eq!(
outcome.config.plugin_trust.enforcement,
PluginTrustEnforcement::Strict
);
}
}