Skip to main content

bamboo_config/
config.rs

1//! Configuration management for Bamboo agent
2//!
3//! This module provides unified configuration types and loading logic for the entire
4//! Bamboo agent system. It supports multiple LLM providers, proxy settings,
5//! and JSON configuration format.
6//!
7//! # Configuration File
8//!
9//! Root configuration is stored in `config.json` under the unified data
10//! directory (defaults to `${HOME}/.bamboo/`). Memory, sub-agent, and legacy
11//! provider settings are independently persisted in `memory.json`,
12//! `subagents.json`, and `providers.json`. Environment variables can override
13//! file values.
14//!
15//! # Example `config.json`
16//!
17//! ```json
18//! {
19//!   "provider": "anthropic",
20//!   "server": {
21//!     "port": 9562,
22//!     "bind": "127.0.0.1"
23//!   }
24//! }
25//! ```
26//!
27//! # Example `providers.json`
28//!
29//! ```json
30//! {
31//!   "anthropic": {
32//!     "api_key_encrypted": "...",
33//!     "model": "claude-3-5-sonnet-20241022"
34//!   }
35//! }
36//! ```
37//!
38//! # Priority Order
39//!
40//! Configuration values are loaded in this order (later overrides earlier):
41//! 1. Code defaults (hardcoded default values)
42//! 2. Config file values (from `${HOME}/.bamboo/config.json`)
43//! 3. Independent sidecars (`memory.json`, `subagents.json`, `providers.json`)
44//! 4. Environment variables (e.g., `BAMBOO_PORT`)
45//! 5. CLI arguments (e.g., `--port 9000`)
46//!
47//! # Environment Variables
48//!
49//! - `BAMBOO_DATA_DIR`: Override data directory location
50//! - `BAMBOO_PORT`: Override server port
51//! - `BAMBOO_BIND`: Override server bind address
52//! - `BAMBOO_PROVIDER`: Override default provider
53//! - `BAMBOO_HEADLESS`: Enable headless authentication mode
54//! - `BAMBOO_OPENAI_API_KEY` / `BAMBOO_ANTHROPIC_API_KEY` / `BAMBOO_GEMINI_API_KEY`:
55//!   Supply a provider's API key from the environment (in-memory only, never
56//!   persisted) — for 12-factor / secret-manager / CI deploys without a
57//!   plaintext key in `providers.json`.
58
59use anyhow::{Context, Result};
60use bamboo_domain::patrol_model_limits::{
61    MAX_EVIDENCE_BYTES, MAX_EVIDENCE_REFS, MAX_INPUT_TOKENS, MAX_OUTPUT_TOKENS,
62    MAX_PROVIDER_INPUT_TOKENS,
63};
64use bamboo_domain::poison::PoisonRecover;
65use serde::{Deserialize, Serialize};
66use serde_json::Value;
67use std::collections::{BTreeMap, BTreeSet, HashMap};
68use std::path::{Path, PathBuf};
69use std::sync::{OnceLock, RwLock};
70
71use crate::keyword_masking::KeywordMaskingConfig;
72use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
73use bamboo_domain::normalize_tool_ref;
74use bamboo_domain::ReasoningEffort;
75
76/// Minimum accepted watchdog deadline. Zero would turn scheduling jitter into
77/// an immediate stream failure, so it is rejected at the configuration boundary.
78pub const MIN_STREAM_TIMEOUT_SECS: u64 = 1;
79/// Maximum accepted watchdog deadline. This keeps a typo from disabling hung
80/// stream detection for days while still allowing operators to accommodate
81/// exceptionally slow reasoning models.
82pub const MAX_STREAM_TIMEOUT_SECS: u64 = 86_400;
83
84fn default_transport_idle_timeout_secs() -> u64 {
85    120
86}
87
88fn default_first_semantic_timeout_secs() -> u64 {
89    600
90}
91
92fn default_semantic_idle_timeout_secs() -> u64 {
93    600
94}
95
96fn deserialize_stream_timeout_secs<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error>
97where
98    D: serde::Deserializer<'de>,
99{
100    use serde::de::Error as _;
101
102    let value = u64::deserialize(deserializer)?;
103    if (MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
104        Ok(value)
105    } else {
106        Err(D::Error::custom(format!(
107            "stream timeout must be between {MIN_STREAM_TIMEOUT_SECS} and \
108             {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
109        )))
110    }
111}
112
113/// LLM stream watchdog policy shared by the main response path and auxiliary
114/// silent consumers.
115///
116/// Transport and semantic progress are deliberately separate. Valid SSE
117/// lifecycle/keepalive frames refresh `transport_idle_timeout_secs` without
118/// hiding a model that never produces semantic output. The first semantic
119/// deadline starts at request dispatch; after the first token/reasoning/tool
120/// delta, `semantic_idle_timeout_secs` becomes the semantic deadline.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(default)]
123pub struct StreamTimeoutConfig {
124    /// Maximum gap between valid transport frames, including SSE keepalives.
125    #[serde(
126        default = "default_transport_idle_timeout_secs",
127        deserialize_with = "deserialize_stream_timeout_secs"
128    )]
129    pub transport_idle_timeout_secs: u64,
130    /// Maximum time from request dispatch to the first semantic chunk.
131    #[serde(
132        default = "default_first_semantic_timeout_secs",
133        deserialize_with = "deserialize_stream_timeout_secs"
134    )]
135    pub first_semantic_timeout_secs: u64,
136    /// Maximum gap between semantic chunks after semantic output has started.
137    #[serde(
138        default = "default_semantic_idle_timeout_secs",
139        deserialize_with = "deserialize_stream_timeout_secs"
140    )]
141    pub semantic_idle_timeout_secs: u64,
142}
143
144impl Default for StreamTimeoutConfig {
145    fn default() -> Self {
146        Self {
147            transport_idle_timeout_secs: default_transport_idle_timeout_secs(),
148            first_semantic_timeout_secs: default_first_semantic_timeout_secs(),
149            semantic_idle_timeout_secs: default_semantic_idle_timeout_secs(),
150        }
151    }
152}
153
154impl StreamTimeoutConfig {
155    /// Validate values created programmatically (serde performs the same check
156    /// while loading `config.json`).
157    pub fn validate(&self) -> std::result::Result<(), String> {
158        for (name, value) in [
159            (
160                "transport_idle_timeout_secs",
161                self.transport_idle_timeout_secs,
162            ),
163            (
164                "first_semantic_timeout_secs",
165                self.first_semantic_timeout_secs,
166            ),
167            (
168                "semantic_idle_timeout_secs",
169                self.semantic_idle_timeout_secs,
170            ),
171        ] {
172            if !(MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
173                return Err(format!(
174                    "{name} must be between {MIN_STREAM_TIMEOUT_SECS} and \
175                     {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
176                ));
177            }
178        }
179        Ok(())
180    }
181}
182
183/// Bounds for the initial-request retry policy. The attempt ceiling keeps a
184/// configuration write from widening the HTTP retry loop beyond what the
185/// request budget can pay for.
186pub const MIN_LLM_RETRY_ATTEMPTS: u32 = 1;
187pub const MAX_LLM_RETRY_ATTEMPTS: u32 = 12;
188pub const MIN_LLM_RETRY_DELAY_MS: u64 = 1;
189pub const MAX_LLM_RETRY_DELAY_MS: u64 = 300_000;
190pub const MIN_LLM_RETRY_ATTEMPT_TIMEOUT_MS: u64 = 1_000;
191pub const MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS: u64 = 600_000;
192pub const MIN_LLM_RETRY_TOTAL_BUDGET_MS: u64 = 1_000;
193pub const MAX_LLM_RETRY_TOTAL_BUDGET_MS: u64 = 3_600_000;
194
195fn default_llm_retry_attempts() -> u32 {
196    // Eight attempts absorb a relay blip or a queue spike instead of losing the
197    // turn. An attempt is only re-sent after the previous one failed, so this
198    // raises tolerance without raising the successful-path cost.
199    8
200}
201
202fn default_llm_retry_attempt_timeout_ms() -> u64 {
203    180_000
204}
205
206fn default_llm_retry_total_budget_ms() -> u64 {
207    900_000
208}
209
210fn default_llm_retry_base_delay_ms() -> u64 {
211    500
212}
213
214fn default_llm_retry_max_delay_ms() -> u64 {
215    30_000
216}
217
218/// Initial-request retry policy, operator-tunable from the settings page.
219///
220/// Only establishment of the initial HTTP response is retried; a successful
221/// stream is never replayed. `attempt_timeout_ms` is a *discard* deadline: on
222/// expiry the whole request body is dropped and sent again, so it must clear
223/// real first-byte latency rather than the median.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(default)]
226pub struct LlmRetryConfig {
227    /// Total attempts including the first. `1` disables retrying.
228    #[serde(default = "default_llm_retry_attempts")]
229    pub max_attempts: u32,
230    /// Deadline for response headers on one attempt.
231    #[serde(default = "default_llm_retry_attempt_timeout_ms")]
232    pub attempt_timeout_ms: u64,
233    /// Absolute budget shared by every attempt of one initial request.
234    #[serde(default = "default_llm_retry_total_budget_ms")]
235    pub total_budget_ms: u64,
236    /// Base exponential backoff step.
237    #[serde(default = "default_llm_retry_base_delay_ms")]
238    pub base_delay_ms: u64,
239    /// Upper bound on any single computed backoff sleep.
240    #[serde(default = "default_llm_retry_max_delay_ms")]
241    pub max_delay_ms: u64,
242}
243
244impl Default for LlmRetryConfig {
245    fn default() -> Self {
246        Self {
247            max_attempts: default_llm_retry_attempts(),
248            attempt_timeout_ms: default_llm_retry_attempt_timeout_ms(),
249            total_budget_ms: default_llm_retry_total_budget_ms(),
250            base_delay_ms: default_llm_retry_base_delay_ms(),
251            max_delay_ms: default_llm_retry_max_delay_ms(),
252        }
253    }
254}
255
256impl LlmRetryConfig {
257    /// Reject an out-of-range policy at the API boundary instead of letting it
258    /// widen the retry loop silently.
259    pub fn validate(&self) -> std::result::Result<(), String> {
260        let checks = [
261            (
262                "max_attempts",
263                u64::from(self.max_attempts),
264                u64::from(MIN_LLM_RETRY_ATTEMPTS),
265                u64::from(MAX_LLM_RETRY_ATTEMPTS),
266            ),
267            (
268                "attempt_timeout_ms",
269                self.attempt_timeout_ms,
270                MIN_LLM_RETRY_ATTEMPT_TIMEOUT_MS,
271                MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS,
272            ),
273            (
274                "total_budget_ms",
275                self.total_budget_ms,
276                MIN_LLM_RETRY_TOTAL_BUDGET_MS,
277                MAX_LLM_RETRY_TOTAL_BUDGET_MS,
278            ),
279            (
280                "base_delay_ms",
281                self.base_delay_ms,
282                MIN_LLM_RETRY_DELAY_MS,
283                MAX_LLM_RETRY_DELAY_MS,
284            ),
285            (
286                "max_delay_ms",
287                self.max_delay_ms,
288                MIN_LLM_RETRY_DELAY_MS,
289                MAX_LLM_RETRY_DELAY_MS,
290            ),
291        ];
292        for (name, value, min, max) in checks {
293            if !(min..=max).contains(&value) {
294                return Err(format!(
295                    "{name} must be between {min} and {max}, got {value}"
296                ));
297            }
298        }
299        if self.base_delay_ms > self.max_delay_ms {
300            return Err(format!(
301                "base_delay_ms ({}) must not exceed max_delay_ms ({})",
302                self.base_delay_ms, self.max_delay_ms
303            ));
304        }
305        Ok(())
306    }
307}
308
309/// A user-managed environment variable that is injected into Bash tool processes.
310///
311/// Secret entries are stored by reference in the isolated credential store:
312/// `value` is runtime-only for those entries, while `credential_ref` and
313/// `configured` are the only secret metadata persisted in ordinary config.
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
315pub struct EnvVarEntry {
316    /// Variable name (must match `^[A-Za-z_][A-Za-z0-9_]*$`).
317    pub name: String,
318    /// Plaintext value – populated in memory after hydration.
319    /// For `secret=true` entries this field is empty on disk.
320    #[serde(default, skip_serializing_if = "String::is_empty")]
321    pub value: String,
322    /// Whether this variable contains sensitive data (token, password, etc.).
323    #[serde(default)]
324    pub secret: bool,
325    /// Legacy inline ciphertext, accepted on read only so startup migration can
326    /// extract it. New serializers never emit this field.
327    #[serde(default, skip_serializing)]
328    pub value_encrypted: Option<String>,
329    /// Stable isolated-store reference for secret entries.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub credential_ref: Option<crate::CredentialRef>,
332    /// Durable status metadata; never inferred from a public mask.
333    #[serde(default)]
334    pub configured: bool,
335    /// Optional human-readable description.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub description: Option<String>,
338}
339
340/// Default work area configuration.
341///
342/// Allows Bamboo to operate without an explicit initial workspace while still
343/// providing a stable fallback directory for relative-path tool execution.
344#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
345pub struct DefaultWorkAreaConfig {
346    /// Optional default filesystem path used when a session has no active workspace.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub path: Option<String>,
349}
350
351/// User-selected locations for new user worktrees. Git remains the registry.
352#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
353pub struct WorktreeSettings {
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub default_root: Option<String>,
356    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
357    pub project_roots: BTreeMap<String, String>,
358}
359
360impl WorktreeSettings {
361    pub fn is_empty(&self) -> bool {
362        self.default_root.is_none() && self.project_roots.is_empty()
363    }
364}
365
366/// Access control configuration for password-based UI/API gating.
367#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
368pub struct AccessControlConfig {
369    /// Whether password protection is enabled.
370    #[serde(default)]
371    pub password_enabled: bool,
372    /// A malformed legacy verifier or device record was isolated into the
373    /// encrypted recovery store and needs an explicit user repair. Runtime
374    /// authorization treats this as fail-closed even when no usable verifier
375    /// can be hydrated.
376    #[serde(default)]
377    pub repair_required: bool,
378    /// Runtime-only password verifier hash. Legacy documents may still
379    /// deserialize it for migration, but ordinary section serialization never
380    /// writes verifier material.
381    #[serde(default, skip_serializing)]
382    pub password_hash: Option<String>,
383    /// Runtime-only password verifier salt.
384    #[serde(default, skip_serializing)]
385    pub password_salt: Option<String>,
386    /// Stable reference to the encrypted verifier record.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub password_credential_ref: Option<crate::CredentialRef>,
389    /// Durable configured metadata for redacted clients/runtime readiness.
390    #[serde(default)]
391    pub password_configured: bool,
392    /// Last update timestamp for auditing / debugging.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub updated_at: Option<String>,
395    /// v2 (#181): issued per-device tokens. Empty = root-password-only mode
396    /// (back-compat with old instances). Each entry stores only the token hash;
397    /// the plaintext token is returned to the client once at pairing time.
398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
399    pub devices: Vec<DeviceCredential>,
400}
401
402/// A single paired device's credential (v2-P2 per-device token, #181).
403///
404/// The server stores only `token_hash` (never the plaintext token). The hash is
405/// computed with the SAME construction as the access password — `SHA-256(salt ||
406/// token)` — so no new crypto dependency is introduced (`docs/api-v2-transport.md`
407/// §4.2).
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
409pub struct DeviceCredential {
410    /// Server-generated stable id: `bamboo_<12 hex>`.
411    pub device_id: String,
412    /// Human-readable label, e.g. "iPhone 15".
413    pub label: String,
414    /// Runtime-only `SHA-256(hex_decode(token_salt) || token)`, hex-encoded.
415    #[serde(default, skip_serializing)]
416    pub token_hash: String,
417    /// Runtime-only per-device salt (hex-encoded).
418    #[serde(default, skip_serializing)]
419    pub token_salt: String,
420    /// Stable reference to the encrypted device-token verifier record.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub token_credential_ref: Option<crate::CredentialRef>,
423    /// Durable configured metadata for runtime readiness.
424    #[serde(default)]
425    pub token_configured: bool,
426    /// RFC3339 creation timestamp.
427    pub created_at: String,
428    /// RFC3339 last-used timestamp (deferred stamping; see PR).
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub last_used_at: Option<String>,
431    /// Whether this device's token has been revoked. A revoked token is rejected
432    /// at the handshake/middleware immediately.
433    #[serde(default)]
434    pub revoked: bool,
435}
436
437/// Memory and background summarization configuration.
438// No `Eq`: `dedup_gardener_min_score` is an f64 (PartialEq only).
439#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
440pub struct MemoryConfig {
441    /// Optional dedicated model for memory/session summarization and reflection.
442    /// Falls back to the provider fast model when unset.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub background_model: Option<String>,
445    /// Desired conversation-summary size as a fraction of the raw source tokens
446    /// represented by that summary. The default keeps roughly 20% of source
447    /// content as durable working memory.
448    #[serde(default = "default_summary_target_ratio")]
449    pub summary_target_ratio: f64,
450    /// Maximum fraction of the summarization model context window consumed by a
451    /// fully rendered map/reduce request, including reserved output and safety
452    /// margin.
453    #[serde(default = "default_summary_safe_window_percent")]
454    pub summary_safe_window_percent: u8,
455    /// Whether lightweight automatic Dream-style consolidation should run in the
456    /// background. Default ON (memory redesign L4): each tick no-ops when there is
457    /// no background model configured or no new candidate sessions, so it is free
458    /// until there is real work + a model. Set false to opt out.
459    #[serde(default = "default_true_auto_dream_enabled")]
460    pub auto_dream_enabled: bool,
461    /// Seconds between background auto-Dream ticks (default 30 minutes).
462    /// Each tick still no-ops when there are no new candidate sessions, so raising
463    /// this only lowers how often an active user triggers a real consolidation.
464    #[serde(default = "default_auto_dream_interval_secs")]
465    pub auto_dream_interval_secs: u64,
466    /// Whether project durable-memory index injection is enabled for the main prompt.
467    #[serde(
468        default = "default_true_memory_project_prompt_injection",
469        alias = "memory_project_prompt_injection"
470    )]
471    pub project_prompt_injection: bool,
472    /// Whether automatic relevant durable-memory recall is enabled for the main prompt.
473    #[serde(
474        default = "default_true_memory_relevant_recall",
475        alias = "memory_relevant_recall"
476    )]
477    pub relevant_recall: bool,
478    /// Whether relevant durable-memory recall should rerank lexical shortlist candidates
479    /// using the configured memory/background model.
480    #[serde(default, alias = "memory_relevant_recall_rerank")]
481    pub relevant_recall_rerank: bool,
482    /// Whether Dream prompt injection should prefer project Dream and only use global Dream as fallback.
483    #[serde(
484        default = "default_true_memory_project_first_dream",
485        alias = "memory_project_first_dream"
486    )]
487    pub project_first_dream: bool,
488    /// Whether the ledger agenda (overdue/upcoming prospective records — todos,
489    /// events, reminders) is injected into the main prompt. Free when the
490    /// ledger is empty: the section is simply omitted.
491    #[serde(
492        default = "default_true_memory_ledger_agenda",
493        alias = "memory_ledger_agenda_injection"
494    )]
495    pub ledger_agenda_injection: bool,
496    /// Whether the background ledger gardener runs (expires past events/reminders,
497    /// reconciles record↔schedule drift, distills completed records into durable
498    /// memory). Expiry and reconciliation are deterministic and free; only
499    /// distillation uses the background model, and it no-ops without one.
500    #[serde(default = "default_true_ledger_gardener_enabled")]
501    pub ledger_gardener_enabled: bool,
502    /// Seconds between ledger gardener runs (default 6 hours).
503    #[serde(default = "default_ledger_gardener_interval_secs")]
504    pub ledger_gardener_interval_secs: u64,
505    /// Whether the ledger gardener's distillation pass (completed records →
506    /// durable memories via the background model) is enabled.
507    #[serde(default = "default_true_ledger_distillation_enabled")]
508    pub ledger_distillation_enabled: bool,
509    /// DEPRECATED (memory redesign L3): the "Refine" Dream mode — rewriting the
510    /// notebook from its own prior prose — was retired because a self-referential
511    /// narrative rewrite drifts from durable truth and silently over-merges. The
512    /// notebook is now always a grounded VIEW of the durable memory index (Rebuild)
513    /// or a session bootstrap (Incremental). This field is IGNORED; it is retained
514    /// only so existing config files that set it still deserialize.
515    #[serde(default, alias = "memory_dream_refine_mode")]
516    pub dream_refine_mode: bool,
517    /// Whether the background "gardener" may use the LLM to split/merge "blob" memories.
518    /// Default ON (memory redesign L4). The deterministic blob prefilter is cheap
519    /// and each run is bounded by `gardener_max_splits_per_run`; a run that finds
520    /// nothing, or finds work but has no background model, spends no tokens. Set
521    /// false to opt out.
522    #[serde(
523        default = "default_true_gardener_enabled",
524        alias = "memory_gardener_enabled"
525    )]
526    pub gardener_enabled: bool,
527    /// Seconds between gardener time-triggered runs (default daily). A run may also
528    /// fire early when the library grows — see `gardener_volume_trigger`.
529    #[serde(default = "default_gardener_interval_secs")]
530    pub gardener_interval_secs: u64,
531    /// Run the gardener maintenance pass early (before the next time tick) once this
532    /// many new durable memories have accumulated since the last run, so pileup is
533    /// bounded by growth, not only by the clock (memory redesign L4). 0 disables the
534    /// volume trigger (time-only). Per-run caps still bound the work done.
535    #[serde(default = "default_gardener_volume_trigger")]
536    pub gardener_volume_trigger: usize,
537    /// Hard cap on LLM-backed splits per gardener run (cost ceiling per run).
538    #[serde(default = "default_gardener_max_splits_per_run")]
539    pub gardener_max_splits_per_run: usize,
540    /// Minimum `---` accretions for a memory to be a gardener split candidate.
541    #[serde(default = "default_gardener_min_sections")]
542    pub gardener_min_sections: usize,
543    /// Whether the background dedup gardener may use the LLM to consolidate
544    /// near-duplicate memories. Default ON (memory redesign L4); bounded by
545    /// `dedup_gardener_max_merges_per_run` and no-ops without a model. Set false to
546    /// opt out.
547    #[serde(
548        default = "default_true_dedup_gardener_enabled",
549        alias = "memory_dedup_gardener_enabled"
550    )]
551    pub dedup_gardener_enabled: bool,
552    /// Minimum content-keyword Jaccard (0.0–1.0) for two active memories to be
553    /// flagged as dedup candidates by the deterministic prefilter.
554    #[serde(default = "default_dedup_gardener_min_score")]
555    pub dedup_gardener_min_score: f64,
556    /// Hard cap on LLM-backed consolidations per dedup gardener run (cost ceiling).
557    #[serde(default = "default_dedup_gardener_max_merges_per_run")]
558    pub dedup_gardener_max_merges_per_run: usize,
559    /// Max RECALLABLE (Active/Stale) memories per scope before the capacity gardener
560    /// archives the lowest-value overflow OUT of the recall index (memory redesign
561    /// L5 — archive, never delete; reversible). 0 = unbounded (feature OFF, the
562    /// default): consequential enough to be opt-in, since L4's dedup already curbs
563    /// most growth. `Reference`/`User`/`Feedback` memories are always exempt — so
564    /// the effective floor is the count of exempt Active memories in a scope; set
565    /// this comfortably above that (a capacity below it is a no-op, not a purge).
566    #[serde(default)]
567    pub memory_active_capacity: usize,
568    /// Hard cap on how many memories the capacity gardener archives per run, so a
569    /// large overflow drains gradually instead of in one burst.
570    #[serde(default = "default_capacity_max_archivals_per_run")]
571    pub capacity_max_archivals_per_run: usize,
572    /// Whether the background freshness gardener may conservatively demote Active
573    /// day/week-granularity memories to Stale once they cross their documented
574    /// staleness window (issue #61 phase 2; applied by Jiandu's
575    /// `MemoryStore::expire_stale_granularity`). Default ON,
576    /// matching the other gardener passes: deterministic (no LLM, no cost), and
577    /// non-destructive — it only ever moves Active → Stale, never archives or
578    /// deletes. Set false to opt out.
579    #[serde(default = "default_true_granularity_freshness_gardener_enabled")]
580    pub granularity_freshness_gardener_enabled: bool,
581}
582
583impl Default for MemoryConfig {
584    fn default() -> Self {
585        Self {
586            background_model: None,
587            summary_target_ratio: default_summary_target_ratio(),
588            summary_safe_window_percent: default_summary_safe_window_percent(),
589            auto_dream_enabled: default_true_auto_dream_enabled(),
590            auto_dream_interval_secs: default_auto_dream_interval_secs(),
591            project_prompt_injection: default_true_memory_project_prompt_injection(),
592            relevant_recall: default_true_memory_relevant_recall(),
593            relevant_recall_rerank: false,
594            project_first_dream: default_true_memory_project_first_dream(),
595            ledger_agenda_injection: default_true_memory_ledger_agenda(),
596            ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
597            ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
598            ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
599            dream_refine_mode: false,
600            gardener_enabled: default_true_gardener_enabled(),
601            gardener_interval_secs: default_gardener_interval_secs(),
602            gardener_volume_trigger: default_gardener_volume_trigger(),
603            gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
604            gardener_min_sections: default_gardener_min_sections(),
605            dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
606            dedup_gardener_min_score: default_dedup_gardener_min_score(),
607            dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
608            memory_active_capacity: 0,
609            capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
610            granularity_freshness_gardener_enabled:
611                default_true_granularity_freshness_gardener_enabled(),
612        }
613    }
614}
615
616fn default_summary_target_ratio() -> f64 {
617    0.20
618}
619
620fn default_summary_safe_window_percent() -> u8 {
621    80
622}
623
624fn default_true_granularity_freshness_gardener_enabled() -> bool {
625    true
626}
627
628fn default_capacity_max_archivals_per_run() -> usize {
629    50
630}
631
632fn default_true_auto_dream_enabled() -> bool {
633    true
634}
635
636fn default_true_gardener_enabled() -> bool {
637    true
638}
639
640fn default_true_dedup_gardener_enabled() -> bool {
641    true
642}
643
644fn default_true_memory_ledger_agenda() -> bool {
645    true
646}
647
648fn default_true_ledger_gardener_enabled() -> bool {
649    true
650}
651
652fn default_ledger_gardener_interval_secs() -> u64 {
653    21_600
654}
655
656fn default_true_ledger_distillation_enabled() -> bool {
657    true
658}
659
660/// Fire the gardener maintenance pass early once ~this many new memories accumulate
661/// since the last run. Conservative: large enough to avoid thrashing on a few
662/// writes, small enough to bound pileup well under a full (daily) interval.
663fn default_gardener_volume_trigger() -> usize {
664    25
665}
666
667fn default_gardener_interval_secs() -> u64 {
668    86_400
669}
670
671fn default_auto_dream_interval_secs() -> u64 {
672    60 * 30
673}
674
675fn default_gardener_max_splits_per_run() -> usize {
676    8
677}
678
679fn default_gardener_min_sections() -> usize {
680    5
681}
682
683fn default_dedup_gardener_min_score() -> f64 {
684    0.6
685}
686
687fn default_dedup_gardener_max_merges_per_run() -> usize {
688    8
689}
690
691fn default_true_memory_project_prompt_injection() -> bool {
692    true
693}
694
695fn default_true_memory_relevant_recall() -> bool {
696    true
697}
698
699fn default_true_memory_project_first_dream() -> bool {
700    true
701}
702
703/// Per-run resource guardrails (issue #221): a cost/resource ceiling applied
704/// across an entire `AgentRuntime::execute()` call (i.e. one user turn's worth
705/// of internal rounds — the same "run" granularity the round cap uses).
706///
707/// Every field is `None` by default (unlimited), matching the rest of this
708/// config's opt-in-only posture. A per-request `ExecuteRequest::run_budget`
709/// override (HTTP `POST /execute` body) may only TIGHTEN this config-level
710/// default, never loosen it — per field, the effective limit is the minimum
711/// of the two (see [`RunBudgetConfig::merged_with_override`] and
712/// `bamboo_engine::runtime::runtime::AgentRuntime::execute`).
713///
714/// Exceeding any configured limit gracefully stops the run (mirrors the
715/// round-cap exhaustion path: one final summary turn, then a terminal stop
716/// with `runtime.completion_reason` = `"budget_exceeded"` for resource fields
717/// and `"max_rounds_reached"` for the round cap, plus a structured
718/// `AgentEvent::BudgetExceeded`) rather than erroring out — the run stays
719/// resumable.
720#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
721pub struct RunBudgetConfig {
722    /// Maximum total tokens (prompt + completion, actual provider-reported
723    /// usage summed across the run's rounds) before the run is stopped.
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub max_total_tokens: Option<u64>,
726    /// Maximum total tool calls (across every round of the run, not just one
727    /// round — see `max_tool_calls_per_round` for the existing per-round cap)
728    /// before the run is stopped.
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub max_tool_calls: Option<u32>,
731    /// Maximum total `SubAgent` create calls (across the whole run) before the
732    /// run is stopped. Distinct from `subagents.max_concurrent`, which caps how
733    /// many child actor processes run AT ONCE, not how many a single run may
734    /// spawn in total over its lifetime.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub max_subagents: Option<u32>,
737    /// Maximum internal rounds for a single run before the run is gracefully
738    /// stopped. `None` (the default) means unlimited: the loop runs until the
739    /// model stops calling tools, another guardrail trips, or the run is
740    /// cancelled. Setting a cap keeps the issue #29 exhaustion contract
741    /// (`runtime.completion_reason = "max_rounds_reached"`, a visible
742    /// notification, and exactly one final summary turn).
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub max_rounds: Option<u32>,
745}
746
747/// Tighten-only per-field merge: the effective limit is the MINIMUM of the
748/// config default and the request override, with `None` = unlimited.
749fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
750    match (config_default, request) {
751        (Some(a), Some(b)) => Some(a.min(b)),
752        (Some(a), None) => Some(a),
753        (None, Some(b)) => Some(b),
754        (None, None) => None,
755    }
756}
757
758impl RunBudgetConfig {
759    /// Merge a per-request override with this config-level default,
760    /// **tighten-only** (issue #221, PR #539 review): per field, the
761    /// effective limit is the MINIMUM of the two (`None` = unlimited), so a
762    /// `POST /execute` caller can lower a budget below the operator's
763    /// configured ceiling but can never raise or remove it.
764    ///
765    /// Rationale: `run_budget` is a defensive cost circuit-breaker, and the
766    /// server's other guardrails (`max_rounds`, per-round tool caps, …) are
767    /// not client-overridable at all. A client-loosenable ceiling would be no
768    /// ceiling: any caller of `/execute` could send
769    /// `max_total_tokens: u64::MAX` and erase the operator's cap. Overrides
770    /// looser than the config default are silently clamped to it rather than
771    /// rejected — the caller still gets the strictest applicable budget,
772    /// which is always a safe interpretation of their request.
773    pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
774        let Some(over) = request_override else {
775            return *self;
776        };
777        Self {
778            max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
779            max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
780            max_subagents: min_limit(self.max_subagents, over.max_subagents),
781            max_rounds: min_limit(self.max_rounds, over.max_rounds),
782        }
783    }
784}
785
786/// Authentication/provider posture for `executor = "codex"`.
787///
788/// `None` at the containing config field is interpreted as [`Self::Bamboo`],
789/// keeping old documents backward-compatible while making the safe,
790/// parent-routed mode the runtime default.
791#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
792#[serde(rename_all = "snake_case")]
793pub enum CodexAuthMode {
794    /// Use the invoking user's default `~/.codex` auth and configuration.
795    Inherit,
796    /// Isolated `CODEX_HOME`; `OPENAI_API_KEY` must be explicitly forwarded.
797    ApiKey,
798    /// Isolated `CODEX_HOME` with a custom provider and referenced credential.
799    Custom,
800    /// Isolated `CODEX_HOME`; route through this Bamboo server with a per-run token.
801    #[default]
802    Bamboo,
803}
804
805/// Codex 0.144+ only accepts the Responses wire protocol for custom providers.
806#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
807#[serde(rename_all = "snake_case")]
808pub enum CodexWireApi {
809    #[default]
810    Responses,
811}
812
813/// Codex transport used by `executor = "codex"`.
814#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
815#[serde(rename_all = "snake_case")]
816pub enum CodexMode {
817    /// One `codex exec --json` process per activation.
818    #[default]
819    Exec,
820    /// Long-lived `codex app-server` JSON-RPC session with approval relay.
821    AppServer,
822}
823
824/// OS sandbox selected for non-interactive `codex exec` children.
825#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
826#[serde(rename_all = "kebab-case")]
827pub enum CodexSandbox {
828    ReadOnly,
829    WorkspaceWrite,
830    DangerFullAccess,
831}
832
833/// Codex approval policy. Mode-specific validation rejects interactive policy
834/// in exec mode and non-interactive policy in app-server mode.
835#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(rename_all = "kebab-case")]
837pub enum CodexApprovalPolicy {
838    Never,
839    OnFailure,
840    OnRequest,
841}
842
843/// Default number of child levels below a Root (whose depth is zero).
844pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
845
846/// Sub-agent execution settings.
847///
848/// Sub-agents always run as independent **actor** processes — an isolated OS
849/// process with its own context (crash isolation, true parallelism, per-child
850/// resource limits). The historical in-process runtime was removed, so there is
851/// no longer a runtime toggle (a stray `"runtime"`/`"overrides"` key in an old
852/// config is ignored). The worker binary, its arguments, and the discovery
853/// directory are derived automatically (the current `bamboo` executable +
854/// `subagent-worker`); the expert fields below override them only when you run a
855/// custom worker.
856#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
857pub struct SubagentsConfig {
858    /// Maximum child depth below Root=0. Zero disables new child creation.
859    /// Unset preserves the historical four-level limit.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub max_spawn_depth: Option<u32>,
862    /// Maximum actor activations running at once; further spawns wait their
863    /// turn. Default: 200. Warm-idle process retention has a separate, smaller
864    /// bound.
865    #[serde(default, skip_serializing_if = "Option::is_none")]
866    pub max_concurrent: Option<usize>,
867    /// Expert: custom worker binary. Default: the current bamboo executable.
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    pub worker_bin: Option<String>,
870    /// Expert: arguments for the custom worker binary. Default for the
871    /// built-in worker: `["subagent-worker"]`.
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    pub worker_args: Option<Vec<String>>,
874    /// Expert: discovery fabric directory. Default: a per-user temp dir.
875    #[serde(default, skip_serializing_if = "Option::is_none")]
876    pub fabric_dir: Option<String>,
877    /// Expert: `"echo"` swaps in a dependency-free smoke executor (no LLM)
878    /// to verify the actor chain end-to-end; `"claude_code"` drives the
879    /// official Claude Code CLI; `"codex"` drives the selected Codex mode.
880    #[serde(default, skip_serializing_if = "Option::is_none")]
881    pub executor: Option<String>,
882    /// `executor = "claude_code"` only: override the `claude` executable.
883    /// `None` runs `claude` resolved from `PATH`.
884    #[serde(default, skip_serializing_if = "Option::is_none")]
885    pub claude_code_binary: Option<String>,
886    /// `executor = "claude_code"` only: `--model` override. `None` omits the
887    /// flag (CLI default).
888    #[serde(default, skip_serializing_if = "Option::is_none")]
889    pub claude_code_model: Option<String>,
890    /// `executor = "claude_code"` only: `--permission-mode` override. `None`
891    /// still passes an EXPLICIT `default` to the CLI (issue #443 — the
892    /// headless stream-json default is `auto`, which self-approves every
893    /// tool and never asks); it does not mean "omit the flag".
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub claude_code_permission_mode: Option<String>,
896    /// `executor = "claude_code"` only: `true` lets the child inherit the
897    /// invoking user's `~/.claude` MCP servers/skills/settings. `false`/unset
898    /// (the default) isolates it (`--strict-mcp-config` +
899    /// `--setting-sources project`).
900    #[serde(default, skip_serializing_if = "Option::is_none")]
901    pub claude_code_inherit_user_config: Option<bool>,
902    /// `executor = "claude_code"` only: extra env var NAMES forwarded
903    /// verbatim from this process's env to the child, on top of the fixed
904    /// HOME/PATH/SHELL/TERM/LANG/LC_*/TMPDIR/USER/LOGNAME allowlist.
905    /// Forwarding `ANTHROPIC_API_KEY` here is an explicit opt-in that flips
906    /// billing from the CLI's own subscription auth to the API key.
907    #[serde(default, skip_serializing_if = "Option::is_none")]
908    pub claude_code_forward_env: Option<Vec<String>>,
909    /// `executor = "codex"` only: override the `codex` executable. `None`
910    /// resolves `codex` from `PATH`.
911    #[serde(default, skip_serializing_if = "Option::is_none")]
912    pub codex_binary: Option<String>,
913    /// `executor = "codex"` only: `--model` override. `None` uses the CLI
914    /// default model.
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub codex_model: Option<String>,
917    /// `executor = "codex"` only: `exec` (default) or long-lived `app_server`.
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub codex_mode: Option<CodexMode>,
920    /// `executor = "codex"` only: authentication/provider mode. Unset defaults
921    /// to `bamboo`, which keeps provider credentials in the parent process.
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub codex_auth_mode: Option<CodexAuthMode>,
924    /// `codex_auth_mode = "custom"` only: absolute HTTP(S) provider base URL.
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub codex_base_url: Option<String>,
927    /// `codex_auth_mode = "custom"` only. Codex 0.144+ supports `responses`.
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub codex_wire_api: Option<CodexWireApi>,
930    /// `codex_auth_mode = "custom"` only: stable reference to an existing
931    /// provider credential. The plaintext key remains in the credential store
932    /// and is injected into the child process environment only.
933    #[serde(default, skip_serializing_if = "Option::is_none")]
934    pub codex_provider_key_ref: Option<crate::CredentialRef>,
935    /// Extra env var names copied into Codex after `env_clear()`. API-key mode
936    /// requires an explicit `OPENAI_API_KEY` entry; it is never implicit.
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub codex_forward_env: Option<Vec<String>>,
939    /// Explicit Codex sandbox override. Unset derives the sandbox from the
940    /// child permission profile and the parent session's bypass posture.
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    pub codex_sandbox: Option<CodexSandbox>,
943    /// Mode-specific approval policy. Exec resolves to a non-interactive safe
944    /// value; app-server requires `on-request` and routes it to the parent.
945    #[serde(default, skip_serializing_if = "Option::is_none")]
946    pub codex_approval_policy: Option<CodexApprovalPolicy>,
947    /// Permit network access from a workspace-write sandbox.
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub codex_network_access: Option<bool>,
950    /// Opt in to disabling the Codex OS sandbox, but only when the parent run
951    /// is itself in bypass mode. False/unset keeps bypass runs sandboxed.
952    #[serde(default, skip_serializing_if = "Option::is_none")]
953    pub codex_allow_danger_bypass: Option<bool>,
954    /// The active message-broker endpoint the `ask_agent` tool / sub-agent bus
955    /// dials. RUNTIME-ONLY (`#[serde(skip)]`): never read from nor written to
956    /// `config.json`. It is populated in memory each boot by `maybe_embed_broker`
957    /// — either from a user-managed external broker in `<data_dir>/broker.json`,
958    /// or from the freshly-embedded in-process broker (whose ephemeral loopback
959    /// port must NEVER be persisted, else a later boot dials a dead port).
960    #[serde(skip)]
961    pub broker: Option<BrokerClientConfig>,
962    /// Remote placements: pin specific sub-agent roles to resident workers
963    /// reached over `wss://` instead of a locally-spawned subprocess
964    /// (remote-actor-plan §3.4 / P1.5, #193). Empty (the default) keeps every
965    /// role on the local path — fully back-compatible: an old config with no
966    /// `remote_placements` key deserializes to an empty vec.
967    #[serde(default, skip_serializing_if = "Vec::is_empty")]
968    pub remote_placements: Vec<RemoteActorPlacement>,
969    /// Schedulable placements: route specific sub-agent roles to a LIVE worker
970    /// resolved from the agent registry at run time, instead of a locally-spawned
971    /// subprocess (remote-actor-plan §3.4 / P2b, #181). Unlike `remote_placements`
972    /// (a fixed endpoint), a schedulable placement names a logical `pool` and a
973    /// `registry_url`; the engine queries the registry for live workers in that
974    /// pool and picks one. Empty (the default) keeps every role on the local path
975    /// — fully back-compatible: an old config with no `schedulable_placements` key
976    /// deserializes to an empty vec.
977    ///
978    /// PRECEDENCE: if a role appears in BOTH `remote_placements` and
979    /// `schedulable_placements`, the fixed remote placement wins (it is resolved
980    /// first in `build_spec`).
981    #[serde(default, skip_serializing_if = "Vec::is_empty")]
982    pub schedulable_placements: Vec<SchedulablePlacement>,
983    /// Per-role allowlist scoping which host-bound MCP tools a sub-agent role
984    /// may see/call through the orchestrator's MCP proxy (issue #54;
985    /// `bamboo_broker::RoleToolAllowlist`). Read and enforced
986    /// ORCHESTRATOR-side when wiring `serve_mcp_proxy` — this is deliberately
987    /// NOT part of the worker-facing `McpProxyConfig` a deployed worker
988    /// receives, because a worker self-declaring its own allowlist would be
989    /// insecure (it could simply claim to be unrestricted). A role absent
990    /// from this list is unrestricted (sees/can call every proxiable tool),
991    /// so adding this policy never silently strips tools from an
992    /// already-deployed role you have not listed here. Empty (the default)
993    /// keeps every role unrestricted — fully backward compatible with
994    /// pre-#54 behavior.
995    ///
996    /// Role AND tool names are matched by exact string equality against the
997    /// worker-asserted `AgentRef.role` / the requested tool name — see
998    /// `RoleToolAllowlist`'s doc comment for the resulting self-asserted-role
999    /// caveat (this policy is adequate against a confused/hallucinating
1000    /// worker, not a malicious one that lies about its own role).
1001    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1002    pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
1003}
1004
1005impl SubagentsConfig {
1006    pub fn effective_max_spawn_depth(&self) -> u32 {
1007        self.max_spawn_depth.unwrap_or(DEFAULT_MAX_SPAWN_DEPTH)
1008    }
1009}
1010
1011/// One role's MCP proxy tool allowlist entry (issue #54). See
1012/// [`SubagentsConfig::mcp_role_allowlist`].
1013#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1014pub struct McpRoleAllowlistEntry {
1015    /// Sub-agent role this entry restricts — matches the worker-asserted
1016    /// `AgentRef.role` (itself the child session's `subagent_type` /
1017    /// `ChildIdentity.role`). There is no fixed registry of valid roles in
1018    /// this codebase (roles are free-form profile ids), so a typo here is
1019    /// NOT caught against a "known roles" list — only structurally (blank
1020    /// names are dropped, duplicates warn) at load time. Double-check this
1021    /// against the role string your profile/deploy config actually uses.
1022    pub role: String,
1023    /// Tool names this role may see in its manifest / call through the
1024    /// proxy, matched by exact string equality against the backend's
1025    /// registered tool name. An entry with an EMPTY list is an explicit
1026    /// lockout (no tools) for that role, distinct from the role being absent
1027    /// from this Vec entirely (unrestricted). Validated at load time against
1028    /// the orchestrator's live MCP tool set where available — an unknown
1029    /// name is still enforced (kept) but logged as a likely typo.
1030    #[serde(default)]
1031    pub tools: Vec<String>,
1032}
1033
1034/// Operator-owned hard constraints for a remote WorkerHost. The broker's
1035/// authenticated peer policy advertises capacity; it does not choose the
1036/// trust/data boundary for a Child. Missing requirements leave an explicitly
1037/// selected remote placement unavailable rather than inferring authority from
1038/// the worker's own capabilities.
1039#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1040#[serde(deny_unknown_fields)]
1041pub struct OperatorPlacementRequirements {
1042    pub trust_zone: String,
1043    #[serde(default, skip_serializing_if = "Option::is_none")]
1044    pub workspace_label: Option<String>,
1045    #[serde(default, skip_serializing_if = "Option::is_none")]
1046    pub network_zone: Option<String>,
1047    #[serde(default)]
1048    pub require_network_isolation: bool,
1049    /// Operator-declared tools the selected Host must support. The runner adds
1050    /// a native tool ceiling when one is bound to the Child.
1051    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
1052    pub required_tools: std::collections::BTreeSet<String>,
1053}
1054
1055/// The fixed parent identity used by a scoped broker pool. Every candidate
1056/// WorkerHost is separately selected by its authenticated observation.
1057#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1058#[serde(deny_unknown_fields)]
1059pub struct SchedulableBrokerParent {
1060    pub parent_mailbox: String,
1061    pub parent_role: String,
1062}
1063
1064/// Route a role to a capacity-managed WorkerHost pool through a scoped WSS
1065/// broker. The operator supplies the parent identity and hard constraints;
1066/// legacy entries remain readable but unavailable until migrated.
1067#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1068pub struct SchedulablePlacement {
1069    /// Sub-agent role this targets (matches the child session's
1070    /// `metadata["subagent_type"]`).
1071    pub role: String,
1072    /// Logical pool name — the registry `role` to query for live workers.
1073    pub pool: String,
1074    /// Scoped WSS broker endpoint. The old HTTP registry was retired.
1075    #[serde(default, skip_serializing_if = "String::is_empty")]
1076    pub registry_url: String,
1077    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
1078    /// `auth_ref`). Used for BOTH the registry query and the worker connect.
1079    /// `None` ⇒ query/connect without a bearer (trusted link only).
1080    #[serde(default, skip_serializing_if = "Option::is_none")]
1081    pub token_env: Option<String>,
1082    /// PEM file pinning the scoped broker certificate.
1083    #[serde(default, skip_serializing_if = "Option::is_none")]
1084    pub ca_cert_file: Option<String>,
1085    /// Required for authority-backed scheduling. `registry_url` is the scoped
1086    /// WSS broker URL for this route; legacy entries remain readable but cannot
1087    /// dispatch a Run without a trusted parent identity and requirements.
1088    #[serde(default, skip_serializing_if = "Option::is_none")]
1089    pub broker_parent: Option<SchedulableBrokerParent>,
1090    #[serde(default, skip_serializing_if = "Option::is_none")]
1091    pub placement_requirements: Option<OperatorPlacementRequirements>,
1092}
1093
1094/// Pins a single sub-agent role to a remote resident worker (remote-actor-plan
1095/// §3.4 / P1.5). A child whose `subagent_type` matches `role` is connected over
1096/// `wss://` to `endpoint` (Bearer-authenticated) instead of being spawned as a
1097/// local subprocess. No role match ⇒ that child stays on the local path.
1098///
1099/// The bearer token is NEVER stored here in the clear: `token_env` names the
1100/// environment variable that holds it (mirroring the A2A `auth_ref` pattern),
1101/// read once at runner-build time. A `token_env` that is set-but-unset at build
1102/// time leaves an explicit placement unavailable. A `broker_peer` selection
1103/// is required for execution; legacy direct-worker config remains readable.
1104#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1105pub struct RemoteActorPlacement {
1106    /// Sub-agent role this targets (matches the child session's
1107    /// `metadata["subagent_type"]`).
1108    pub role: String,
1109    /// Broker endpoint for a scoped peer route. Legacy worker endpoints remain
1110    /// readable but are unavailable until migrated to a broker route.
1111    pub endpoint: String,
1112    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
1113    /// `auth_ref`). `None` ⇒ connect without a bearer (trusted link only).
1114    #[serde(default, skip_serializing_if = "Option::is_none")]
1115    pub token_env: Option<String>,
1116    /// PEM file pinning a self-signed worker cert. `None` ⇒ default webpki roots
1117    /// (or plaintext `ws://`).
1118    #[serde(default, skip_serializing_if = "Option::is_none")]
1119    pub ca_cert_file: Option<String>,
1120    /// Explicit scoped broker route. Absence keeps the placement selected but
1121    /// unavailable, never a direct parent-to-worker connection.
1122    #[serde(default, skip_serializing_if = "Option::is_none")]
1123    pub broker_peer: Option<RemoteBrokerPeer>,
1124    #[serde(default, skip_serializing_if = "Option::is_none")]
1125    pub placement_requirements: Option<OperatorPlacementRequirements>,
1126}
1127
1128/// Operator-pinned transport identities; these do not grant Actor ownership.
1129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1130#[serde(deny_unknown_fields)]
1131pub struct RemoteBrokerPeer {
1132    pub parent_mailbox: String,
1133    pub worker_mailbox: String,
1134    pub parent_role: Option<String>,
1135    pub worker_role: Option<String>,
1136}
1137impl RemoteBrokerPeer {
1138    pub fn valid(&self) -> bool {
1139        fn identifier(s: &str, mailbox: bool) -> bool {
1140            !s.is_empty()
1141                && s.len() <= 256
1142                && s != "."
1143                && s != ".."
1144                && s.bytes()
1145                    .all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
1146                && (!mailbox || !s.bytes().any(|b| b.is_ascii_uppercase()))
1147        }
1148        identifier(&self.parent_mailbox, true)
1149            && identifier(&self.worker_mailbox, true)
1150            && self.parent_mailbox != self.worker_mailbox
1151            && self
1152                .parent_role
1153                .as_deref()
1154                .is_none_or(|s| identifier(s, false))
1155            && self
1156                .worker_role
1157                .as_deref()
1158                .is_some_and(|s| identifier(s, false))
1159    }
1160}
1161
1162/// How to reach the central sub-agent message broker (`bamboo broker serve`).
1163#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
1164pub struct BrokerClientConfig {
1165    /// Broker WebSocket endpoint, e.g. `ws://broker-host:9600`.
1166    pub endpoint: String,
1167    /// Bearer token presented in the broker handshake.
1168    ///
1169    /// Runtime-only plaintext hydrated from the isolated credential store.
1170    /// Legacy `broker.json` files may still deserialize this field so the
1171    /// manifest migration can extract it, but serializers never emit it.
1172    #[serde(default, skip_serializing)]
1173    pub token: String,
1174    /// Legacy inline ciphertext accepted only for credential migration.
1175    #[serde(default, skip_serializing)]
1176    pub token_encrypted: Option<String>,
1177    /// Stable reference to the external broker bearer token.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub credential_ref: Option<crate::CredentialRef>,
1180    /// Durable configured metadata. Runtime hydration still verifies that the
1181    /// referenced credential exists and decrypts successfully.
1182    #[serde(default)]
1183    pub configured: bool,
1184}
1185
1186impl std::fmt::Debug for BrokerClientConfig {
1187    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1188        formatter
1189            .debug_struct("BrokerClientConfig")
1190            .field("endpoint", &self.endpoint)
1191            .field("credential_ref", &self.credential_ref)
1192            .field("configured", &self.configured)
1193            .finish_non_exhaustive()
1194    }
1195}
1196
1197/// Native desktop (OS-notification) delivery channel.
1198#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1199pub struct DesktopChannelConfig {
1200    /// `None` = auto: on when Bamboo runs as a standalone `bamboo serve`
1201    /// process, off when spawned as a sidecar under `--parent-pid` (a native
1202    /// shell such as Bodhi owns notification UX in that mode — desktop
1203    /// notifications from both the sidecar and the shell would double-fire).
1204    /// `Some(_)` is an explicit user override of that default in either
1205    /// direction.
1206    #[serde(default, skip_serializing_if = "Option::is_none")]
1207    pub enabled: Option<bool>,
1208}
1209
1210/// [ntfy.sh](https://ntfy.sh) push notification channel (self-hostable).
1211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1212pub struct NtfyChannelConfig {
1213    #[serde(default)]
1214    pub enabled: bool,
1215    /// ntfy server base URL (public ntfy.sh or a self-hosted instance).
1216    #[serde(default = "default_ntfy_base_url")]
1217    pub base_url: String,
1218    /// Topic to publish to. Priority mapping from notification category is
1219    /// left to the delivery sink, not configured here.
1220    #[serde(default)]
1221    pub topic: String,
1222    /// Access token for a protected/self-hosted ntfy instance (public ntfy.sh
1223    /// topics need none).
1224    ///
1225    /// Secret plaintext hydrated in memory from the isolated credential store;
1226    /// never serialized in ordinary config.
1227    #[serde(default, skip_serializing)]
1228    pub token: Option<String>,
1229    /// Legacy encrypted ciphertext accepted only for migration.
1230    #[serde(default, skip_serializing)]
1231    pub token_encrypted: Option<String>,
1232    /// Stable isolated credential-store reference.
1233    #[serde(default, skip_serializing_if = "Option::is_none")]
1234    pub credential_ref: Option<crate::CredentialRef>,
1235    /// Whether the referenced credential is expected to exist.
1236    #[serde(default)]
1237    pub configured: bool,
1238}
1239
1240impl Default for NtfyChannelConfig {
1241    fn default() -> Self {
1242        Self {
1243            enabled: false,
1244            base_url: default_ntfy_base_url(),
1245            topic: String::new(),
1246            token: None,
1247            token_encrypted: None,
1248            credential_ref: None,
1249            configured: false,
1250        }
1251    }
1252}
1253
1254fn default_ntfy_base_url() -> String {
1255    "https://ntfy.sh".to_string()
1256}
1257
1258/// [Bark](https://github.com/Finb/Bark) iOS push notification channel.
1259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1260pub struct BarkChannelConfig {
1261    #[serde(default)]
1262    pub enabled: bool,
1263    /// Bark server base URL (public api.day.app or a self-hosted instance).
1264    #[serde(default = "default_bark_base_url")]
1265    pub base_url: String,
1266    /// Bark device key identifying the target iOS device.
1267    ///
1268    /// Secret plaintext hydrated in memory from the isolated credential store;
1269    /// never serialized in ordinary config.
1270    #[serde(default, skip_serializing)]
1271    pub device_key: Option<String>,
1272    /// Legacy encrypted ciphertext accepted only for migration.
1273    #[serde(default, skip_serializing)]
1274    pub device_key_encrypted: Option<String>,
1275    /// Stable isolated credential-store reference.
1276    #[serde(default, skip_serializing_if = "Option::is_none")]
1277    pub credential_ref: Option<crate::CredentialRef>,
1278    /// Whether the referenced credential is expected to exist.
1279    #[serde(default)]
1280    pub configured: bool,
1281}
1282
1283impl Default for BarkChannelConfig {
1284    fn default() -> Self {
1285        Self {
1286            enabled: false,
1287            base_url: default_bark_base_url(),
1288            device_key: None,
1289            device_key_encrypted: None,
1290            credential_ref: None,
1291            configured: false,
1292        }
1293    }
1294}
1295
1296fn default_bark_base_url() -> String {
1297    "https://api.day.app".to_string()
1298}
1299
1300/// Notification delivery channels: native desktop plus push-relay services.
1301///
1302/// Additive/back-compat: an absent `notifications` key in `config.json`
1303/// deserializes to the defaults (desktop auto, ntfy/bark disabled).
1304#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1305pub struct NotificationsConfig {
1306    #[serde(default)]
1307    pub desktop: DesktopChannelConfig,
1308    #[serde(default)]
1309    pub ntfy: NtfyChannelConfig,
1310    #[serde(default)]
1311    pub bark: BarkChannelConfig,
1312}
1313
1314/// One IM-platform bridge configured under `[[connect.platforms]]` —
1315/// bamboo-connect (issue #452 / epic #447): drives a bamboo session from an
1316/// external chat platform (Telegram first).
1317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1318pub struct ConnectPlatformConfig {
1319    /// Stable per-entry identifier (#496). Not part of the original schema —
1320    /// absent on legacy/hand-written entries and on a freshly-echoed new
1321    /// entry from a client. [`Config::save_to_dir`] assigns one (a random
1322    /// UUID) to any entry that lacks it as part of the normal save path
1323    /// (migration-on-write); load never mutates/rewrites the config to
1324    /// backfill it, per #493's never-overwrite-until-confirmed semantics.
1325    ///
1326    /// Used by [`crate::patch::preserve_masked_connect_secrets`] as the
1327    /// FIRST resolution strategy for a masked secret in a settings PATCH,
1328    /// ahead of the positional/`type`-based fallbacks (#490/#492) — an exact
1329    /// id match unambiguously identifies the same logical entry even when
1330    /// two entries share the same `platform_type` and have been reordered,
1331    /// which position+type alone cannot always disambiguate.
1332    #[serde(default, skip_serializing_if = "Option::is_none")]
1333    pub id: Option<String>,
1334    /// Optional first-class Project id assigned to newly-created sessions from
1335    /// this connector. Existing connector sessions keep their persisted
1336    /// membership when configuration changes.
1337    #[serde(default, skip_serializing_if = "Option::is_none")]
1338    pub project_id: Option<bamboo_domain::ProjectId>,
1339    /// Platform adapter selector, e.g. `"telegram"`. Unrecognized values are
1340    /// skipped (with a startup warning) rather than failing config load —
1341    /// forward-compatible with future adapters (Feishu/Slack).
1342    #[serde(rename = "type")]
1343    pub platform_type: String,
1344    /// Platform bot/API token.
1345    ///
1346    /// Secret: encrypted at rest in `token_encrypted`; this plaintext field is
1347    /// never serialized and is hydrated in memory on load (mirrors
1348    /// [`NtfyChannelConfig::token`] / [`BarkChannelConfig::device_key`]).
1349    #[serde(default, skip_serializing)]
1350    pub token: Option<String>,
1351    /// Encrypted ciphertext of `token` (the at-rest representation).
1352    #[serde(default, skip_serializing_if = "Option::is_none")]
1353    pub token_encrypted: Option<String>,
1354    /// Stable reference to the isolated token credential.
1355    #[serde(default, skip_serializing_if = "Option::is_none")]
1356    pub token_credential_ref: Option<crate::CredentialRef>,
1357    /// Durable metadata used by redacted clients without exposing a value.
1358    #[serde(default)]
1359    pub token_configured: bool,
1360    /// Platform app id (Feishu `app_id`). Not a secret — serialized normally.
1361    /// Unused by the Telegram adapter.
1362    #[serde(default, skip_serializing_if = "Option::is_none")]
1363    pub app_id: Option<String>,
1364    /// Platform app secret (Feishu `app_secret`).
1365    ///
1366    /// Secret: encrypted at rest in `app_secret_encrypted`; this plaintext
1367    /// field is never serialized and is hydrated in memory on load (mirrors
1368    /// `token` above).
1369    #[serde(default, skip_serializing)]
1370    pub app_secret: Option<String>,
1371    /// Encrypted ciphertext of `app_secret` (the at-rest representation).
1372    #[serde(default, skip_serializing_if = "Option::is_none")]
1373    pub app_secret_encrypted: Option<String>,
1374    /// Stable reference to the isolated app-secret credential.
1375    #[serde(default, skip_serializing_if = "Option::is_none")]
1376    pub app_secret_credential_ref: Option<crate::CredentialRef>,
1377    /// Durable metadata used by redacted clients without exposing a value.
1378    #[serde(default)]
1379    pub app_secret_configured: bool,
1380    /// Platform domain/base-URL selector (Feishu-only today). Not a secret —
1381    /// serialized normally. `None`/`"feishu"` -> open.feishu.cn, `"lark"` ->
1382    /// open.larksuite.com, an `https://` value -> a private-deployment base
1383    /// URL used verbatim. Validation happens in the server registration arm,
1384    /// not here.
1385    #[serde(default, skip_serializing_if = "Option::is_none")]
1386    pub domain: Option<String>,
1387    /// Platform-scoped user ids allowed to drive a session. Deliberately
1388    /// STRICTER than the general secret-mask precedents: an EMPTY list means
1389    /// deny-all (every inbound message is rejected), not allow-all — a
1390    /// startup warning is logged when a platform has no allowed users.
1391    #[serde(default)]
1392    pub allow_from: Vec<String>,
1393    /// User ids allowed to run privileged/admin commands. Parsed from day one
1394    /// but UNUSED in the MVP (#452) — no admin commands exist yet; reserved
1395    /// for the approvals/admin phase of epic #447.
1396    #[serde(default)]
1397    pub admin_from: Vec<String>,
1398}
1399
1400/// bamboo-connect platform bridges: drive bamboo sessions from IM platforms.
1401/// Additive/back-compat: an absent `connect` key in `config.json`
1402/// deserializes to an empty platform list — fully inert (#452).
1403#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1404pub struct ConnectConfig {
1405    #[serde(default)]
1406    pub platforms: Vec<ConnectPlatformConfig>,
1407}
1408
1409fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1410    config.platforms.is_empty()
1411}
1412
1413/// One publisher key trusted to sign plugin bundles.
1414///
1415/// `algorithm` is a plain string (not an enum) so an unrecognized future value
1416/// in an old/new config just never matches during verification rather than
1417/// failing to deserialize — additive/forward-compatible, matching this
1418/// crate's other config sections. Only `"ed25519"` is understood today.
1419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1420pub struct TrustedKey {
1421    /// Human-readable label (surfaced in logs/CLI output); purely descriptive.
1422    pub label: String,
1423    /// Signature algorithm. Only `"ed25519"` is currently verified.
1424    pub algorithm: String,
1425    /// Hex-encoded public key (32 raw bytes for ed25519).
1426    pub public_key: String,
1427}
1428
1429/// Raw Ed25519 public anchor committed by the Jiandu plugin publisher.
1430pub const JIANDU_PLUGIN_PUBLIC_KEY: &str =
1431    "bba9d0e5553401125f939bc3d844e891706d579a611fea9215545771d5306676";
1432
1433/// Default trusted host+path prefix: the `bigduu` GitHub org/user's own repos
1434/// (e.g. `github.com/bigduu/Nova/releases/...`).
1435pub(crate) fn default_trusted_hosts() -> Vec<String> {
1436    vec!["github.com/bigduu/".to_string()]
1437}
1438
1439pub use crate::plugin_trust::{PluginTrustConfig, PublisherMode};
1440
1441/// `plugin_trust.enforcement`: the persistent, config-level form of the
1442/// `--insecure` escape hatch (see [`PluginTrustConfig::enforcement`]).
1443///
1444/// Deserialization accepts either the canonical string form (`"strict"` /
1445/// `"off"`, case-insensitive) or a bool-ish alias (`true` == `Strict`,
1446/// `false` == `Off`) for a hand-edited `config.json` — `true`/`false` read
1447/// naturally as "is enforcement on?". The string form is what
1448/// `bamboo config set plugin_trust.enforcement off` writes (and the only
1449/// form the generic dot-path setter's round-trip check accepts on write,
1450/// since this type always *serializes* back out as a string — see
1451/// `bamboo-config`'s `dot_path` module); the bool alias is a read-side
1452/// convenience for whoever edits `config.json` directly.
1453#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1454#[serde(rename_all = "snake_case")]
1455pub enum PluginTrustEnforcement {
1456    /// Secure by default: the host allowlist, signature, and checksum layers
1457    /// are all enforced (each individually waivable via
1458    /// `--allow-untrusted-host` / `--allow-unsigned` / `--allow-unverified`,
1459    /// or all at once via `--insecure`).
1460    #[default]
1461    Strict,
1462    /// Every `url` plugin install/update skips all three trust layers,
1463    /// without needing any per-install flag. Opt-in only — never the
1464    /// default for a fresh or pre-existing config.
1465    Off,
1466}
1467
1468impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1469    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1470    where
1471        D: serde::Deserializer<'de>,
1472    {
1473        #[derive(Deserialize)]
1474        #[serde(untagged)]
1475        enum Repr {
1476            Bool(bool),
1477            Str(String),
1478        }
1479        match Repr::deserialize(deserializer)? {
1480            Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1481            Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1482            Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1483                "strict" => Ok(PluginTrustEnforcement::Strict),
1484                "off" => Ok(PluginTrustEnforcement::Off),
1485                other => Err(serde::de::Error::custom(format!(
1486                    "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1487                     \"off\""
1488                ))),
1489            },
1490        }
1491    }
1492}
1493
1494/// One `trusted_hosts` entry, split into its host and path-prefix
1495/// components (see [`is_host_trusted`]). `path_prefix` is empty for a
1496/// bare-host entry (e.g. `"example.com"`, meaning "any path on this exact
1497/// host") or starts with `/` (e.g. `"/bigduu/"` from `"github.com/bigduu/"`).
1498struct TrustedHostEntry<'a> {
1499    host: &'a str,
1500    path_prefix: &'a str,
1501}
1502
1503/// Split a raw `trusted_hosts` entry at its first `/` into host + path
1504/// components. Entries are compared against ALREADY-lowercased input by the
1505/// caller ([`is_host_trusted`]), so this does no case normalization itself.
1506fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1507    match entry.find('/') {
1508        Some(index) => TrustedHostEntry {
1509            host: &entry[..index],
1510            path_prefix: &entry[index..],
1511        },
1512        None => TrustedHostEntry {
1513            host: entry,
1514            path_prefix: "",
1515        },
1516    }
1517}
1518
1519/// True when `path` matches `prefix` on a `/` path-component boundary, never
1520/// on a raw byte prefix: exactly equal to `prefix`, or `prefix` ends in `/`
1521/// and `path` starts with it, or the character in `path` immediately
1522/// following `prefix` is `/`. An empty `prefix` (a bare-host trusted_hosts
1523/// entry) matches any path.
1524///
1525/// This is what stops a sibling path from passing as a prefix match — e.g.
1526/// entry `github.com/bigduu` (no trailing slash) must NOT match
1527/// `github.com/bigduu-evil/x`: `"/bigduu-evil/x"` starts with `"/bigduu"` as
1528/// raw bytes, but the character right after the prefix is `-`, not `/`, so
1529/// this correctly refuses it.
1530fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1531    if prefix.is_empty() || path == prefix {
1532        return true;
1533    }
1534    if prefix.ends_with('/') {
1535        return path.starts_with(prefix);
1536    }
1537    path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1538}
1539
1540/// Free function backing [`PluginTrustConfig::is_host_trusted`] — exposed
1541/// separately so callers (and tests) can check a candidate host list without
1542/// constructing a full [`PluginTrustConfig`]/[`Config`].
1543///
1544/// Matches on PARSED URL COMPONENTS, not a raw string prefix: `url` must be
1545/// `https`, its `host_str()` (already correct for userinfo — `user@host` or
1546/// `host@evil.com`-style tricks resolve to the real host, not a decoy — and
1547/// for an explicit port, which `host_str()` excludes) must EQUAL a
1548/// `trusted_hosts` entry's host component (case-insensitively, WHOLE host —
1549/// never a `starts_with`), and its (already dot-segment-normalized by
1550/// `Url::parse`) path must match that entry's path-prefix component on a `/`
1551/// boundary (see [`path_matches_prefix`]). A bare-host entry (no `/` in it)
1552/// has an empty path-prefix, so it matches any path but ONLY on that exact
1553/// host.
1554///
1555/// A prior raw-`starts_with` implementation was defeated by (1) gluing a
1556/// trusted bare host into a longer attacker-controlled one, e.g.
1557/// `trusted.example.com` matching `trusted.example.com.evil.com` /
1558/// `trusted.example.comevil.com`, and (2) a sibling path prefix, e.g.
1559/// `github.com/bigduu` (no trailing slash) matching
1560/// `github.com/bigduu-evil/x`. Component-wise matching closes both: host
1561/// comparison is whole-string equality (no gluing possible), and the path
1562/// check enforces a `/` boundary (no sibling-prefix bypass possible).
1563pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1564    let Ok(parsed) = url::Url::parse(url) else {
1565        return false;
1566    };
1567    if parsed.scheme() != "https" {
1568        return false;
1569    }
1570    let Some(host) = parsed.host_str() else {
1571        return false;
1572    };
1573    let host = host.to_ascii_lowercase();
1574    let path = parsed.path();
1575
1576    trusted_hosts.iter().any(|raw_entry| {
1577        let entry = raw_entry.trim().to_ascii_lowercase();
1578        let parsed_entry = parse_trusted_host_entry(&entry);
1579        host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1580    })
1581}
1582
1583/// Host strategy used when the active model context approaches its input limit.
1584///
1585/// `Summary` remains the compatibility default. `MessageSummary` summarizes only
1586/// historical user/assistant prose and removes completed tool exchanges from the
1587/// active model view. `RetrievalWindow` is opt-in and
1588/// archives old exact messages only after the runtime has verified that the
1589/// current-session history capability is callable and the boundary can be
1590/// durably checkpointed.
1591#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1592#[serde(rename_all = "snake_case")]
1593pub enum ContextManagementStrategy {
1594    #[default]
1595    Summary,
1596    MessageSummary,
1597    RetrievalWindow,
1598}
1599
1600/// Explicit fallback used when retrieval-window cannot satisfy a runtime
1601/// precondition. There is deliberately no implicit summary fallback.
1602#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1603#[serde(rename_all = "snake_case")]
1604pub enum ContextManagementFallbackStrategy {
1605    #[default]
1606    None,
1607    Summary,
1608}
1609
1610fn default_retrieval_window_min_recent_user_turns() -> usize {
1611    3
1612}
1613
1614fn default_context_trigger_usage_ratio() -> f64 {
1615    0.80
1616}
1617
1618fn default_context_target_usage_ratio() -> f64 {
1619    0.25
1620}
1621
1622fn default_retrieval_window_trigger_usage_ratio() -> f64 {
1623    0.80
1624}
1625
1626fn default_retrieval_window_target_usage_ratio() -> f64 {
1627    0.60
1628}
1629
1630fn default_history_tool_required() -> bool {
1631    true
1632}
1633
1634/// Selection and safety policy for the opt-in retrieval-window strategy.
1635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1636#[serde(default)]
1637pub struct RetrievalWindowContextConfig {
1638    #[serde(default = "default_retrieval_window_min_recent_user_turns")]
1639    pub min_recent_user_turns: usize,
1640    #[serde(default = "default_retrieval_window_trigger_usage_ratio")]
1641    pub trigger_usage_ratio: f64,
1642    #[serde(default = "default_retrieval_window_target_usage_ratio")]
1643    pub target_usage_ratio: f64,
1644    #[serde(default = "default_history_tool_required")]
1645    pub history_tool_required: bool,
1646    #[serde(default)]
1647    pub fallback_strategy: ContextManagementFallbackStrategy,
1648}
1649
1650impl Default for RetrievalWindowContextConfig {
1651    fn default() -> Self {
1652        Self {
1653            min_recent_user_turns: default_retrieval_window_min_recent_user_turns(),
1654            trigger_usage_ratio: default_retrieval_window_trigger_usage_ratio(),
1655            target_usage_ratio: default_retrieval_window_target_usage_ratio(),
1656            history_tool_required: default_history_tool_required(),
1657            fallback_strategy: ContextManagementFallbackStrategy::None,
1658        }
1659    }
1660}
1661
1662/// Backward-compatible context-management configuration snapshot.
1663#[derive(Debug, Clone, Serialize, PartialEq)]
1664pub struct ContextManagementConfig {
1665    pub strategy: ContextManagementStrategy,
1666    /// Shared upper bound for proactive compression across every strategy.
1667    /// The effective token boundary can be earlier after output/safety reserves.
1668    pub trigger_usage_ratio: f64,
1669    /// Shared post-compression target relative to the model's complete window.
1670    pub target_usage_ratio: f64,
1671    pub retrieval_window: RetrievalWindowContextConfig,
1672}
1673
1674impl Default for ContextManagementConfig {
1675    fn default() -> Self {
1676        Self {
1677            strategy: ContextManagementStrategy::Summary,
1678            trigger_usage_ratio: default_context_trigger_usage_ratio(),
1679            target_usage_ratio: default_context_target_usage_ratio(),
1680            retrieval_window: RetrievalWindowContextConfig::default(),
1681        }
1682    }
1683}
1684
1685impl ContextManagementConfig {
1686    pub fn is_default(&self) -> bool {
1687        *self == Self::default()
1688    }
1689
1690    pub fn validate(&self) -> std::result::Result<(), String> {
1691        if !self.target_usage_ratio.is_finite()
1692            || !self.trigger_usage_ratio.is_finite()
1693            || self.target_usage_ratio < 0.01
1694            || self.target_usage_ratio >= self.trigger_usage_ratio
1695            || self.trigger_usage_ratio > 1.0
1696            || (self.target_usage_ratio * 100.0).floor()
1697                >= (self.trigger_usage_ratio * 100.0).floor()
1698        {
1699            return Err(
1700                "context_management ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1 with distinct whole-percent boundaries"
1701                    .to_string(),
1702            );
1703        }
1704        let policy = &self.retrieval_window;
1705        if policy.min_recent_user_turns == 0 {
1706            return Err(
1707                "context_management.retrieval_window.min_recent_user_turns must be greater than zero"
1708                    .to_string(),
1709            );
1710        }
1711        if !policy.target_usage_ratio.is_finite()
1712            || !policy.trigger_usage_ratio.is_finite()
1713            || policy.target_usage_ratio < 0.01
1714            || policy.target_usage_ratio >= policy.trigger_usage_ratio
1715            || policy.trigger_usage_ratio > 1.0
1716        {
1717            return Err(
1718                "context_management retrieval ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1"
1719                    .to_string(),
1720            );
1721        }
1722        if self.strategy == ContextManagementStrategy::RetrievalWindow
1723            && !policy.history_tool_required
1724        {
1725            return Err(
1726                "retrieval_window requires history_tool_required=true in this release".to_string(),
1727            );
1728        }
1729        Ok(())
1730    }
1731
1732    /// Resolve the common policy onto a runtime-only budget snapshot. Provider
1733    /// limits and durable session overrides remain unchanged; only maintenance
1734    /// ratios are shared, independent of the selected history representation.
1735    pub fn apply_compression_policy(
1736        &self,
1737        mut budget: bamboo_domain::TokenBudget,
1738    ) -> bamboo_domain::TokenBudget {
1739        budget.compression_trigger_percent = (self.trigger_usage_ratio * 100.0).floor() as u8;
1740        budget.compression_target_percent = (self.target_usage_ratio * 100.0).floor() as u8;
1741        budget
1742    }
1743
1744    /// Compatibility accessor. New runtime paths use the dynamically resolved
1745    /// budget's target percentage, also for retrieval archive receipts.
1746    pub fn retrieval_target_usage_percent(&self) -> u8 {
1747        ((self.target_usage_ratio * 100.0).floor() as u8).max(1)
1748    }
1749}
1750
1751impl<'de> Deserialize<'de> for ContextManagementConfig {
1752    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1753    where
1754        D: serde::Deserializer<'de>,
1755    {
1756        #[derive(Default, Deserialize)]
1757        #[serde(default)]
1758        struct Wire {
1759            strategy: ContextManagementStrategy,
1760            trigger_usage_ratio: Option<f64>,
1761            target_usage_ratio: Option<f64>,
1762            retrieval_window: Option<RetrievalWindowContextConfig>,
1763        }
1764
1765        let wire = Wire::deserialize(deserializer)?;
1766        let retrieval_window = wire.retrieval_window.unwrap_or_default();
1767        // Older clients stored ratios under retrieval_window. Preserve stricter
1768        // settings as common limits, while the old default 80/60 adopts 80/25.
1769        // Explicit common fields take precedence on subsequent saves.
1770        let config = Self {
1771            strategy: wire.strategy,
1772            trigger_usage_ratio: wire.trigger_usage_ratio.unwrap_or_else(|| {
1773                default_context_trigger_usage_ratio().min(retrieval_window.trigger_usage_ratio)
1774            }),
1775            target_usage_ratio: wire.target_usage_ratio.unwrap_or_else(|| {
1776                default_context_target_usage_ratio().min(retrieval_window.target_usage_ratio)
1777            }),
1778            retrieval_window,
1779        };
1780        config.validate().map_err(serde::de::Error::custom)?;
1781        Ok(config)
1782    }
1783}
1784
1785/// The provider route and pre-call input proof for semantic patrol.
1786#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1787#[serde(rename_all = "snake_case")]
1788pub enum PatrolModelProtocol {
1789    LlamaCppRawV1,
1790    RegisteredProviderV1,
1791}
1792
1793/// Independent selection for semantic patrol preparation. Absent disables it.
1794#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1795#[serde(deny_unknown_fields)]
1796pub struct PatrolModelConfig {
1797    pub provider: String,
1798    pub model: String,
1799    pub protocol: PatrolModelProtocol,
1800    /// Numeric loopback activation for the raw profile. Registered providers
1801    /// use the endpoint of the configured instance.
1802    #[serde(default, skip_serializing_if = "Option::is_none")]
1803    pub endpoint: Option<std::net::SocketAddr>,
1804    pub max_input_tokens: std::num::NonZeroU32,
1805    pub max_output_tokens: std::num::NonZeroU32,
1806    pub max_evidence_bytes: std::num::NonZeroUsize,
1807    pub max_evidence_refs: std::num::NonZeroUsize,
1808}
1809
1810impl PatrolModelConfig {
1811    pub fn validate(&self) -> Result<(), String> {
1812        if self.provider.is_empty()
1813            || self.provider.len() > 256
1814            || self
1815                .provider
1816                .chars()
1817                .any(|ch| ch.is_whitespace() || ch.is_control())
1818        {
1819            return Err("patrol_model requires an explicit provider identifier".into());
1820        }
1821        let input_limit = match self.protocol {
1822            PatrolModelProtocol::LlamaCppRawV1 => MAX_INPUT_TOKENS,
1823            PatrolModelProtocol::RegisteredProviderV1 => MAX_PROVIDER_INPUT_TOKENS,
1824        };
1825        if self.max_input_tokens.get() > input_limit
1826            || self.max_output_tokens.get() > MAX_OUTPUT_TOKENS
1827            || self.max_evidence_bytes.get() > MAX_EVIDENCE_BYTES
1828            || self.max_evidence_refs.get() > MAX_EVIDENCE_REFS
1829        {
1830            return Err(format!(
1831                "patrol_model limits must be at most {input_limit} input tokens, \
1832                 {MAX_OUTPUT_TOKENS} output tokens, {MAX_EVIDENCE_BYTES} evidence bytes, \
1833                 and {MAX_EVIDENCE_REFS} evidence references"
1834            ));
1835        }
1836        if self
1837            .endpoint
1838            .is_some_and(|endpoint| !endpoint.ip().is_loopback() || endpoint.port() == 0)
1839        {
1840            return Err(
1841                "patrol_model endpoint requires a numeric loopback address and port".into(),
1842            );
1843        }
1844        match self.protocol {
1845            PatrolModelProtocol::LlamaCppRawV1 if self.model == "gpt-oss-20b" => Ok(()),
1846            PatrolModelProtocol::LlamaCppRawV1 => {
1847                Err("llama_cpp_raw_v1 requires the gpt-oss-20b patrol model".into())
1848            }
1849            PatrolModelProtocol::RegisteredProviderV1
1850                if self.endpoint.is_none()
1851                    && !self.model.is_empty()
1852                    && self.model.len() <= 256
1853                    && !self.model.chars().any(|ch| ch.is_whitespace() || ch.is_control()) =>
1854            {
1855                Ok(())
1856            }
1857            PatrolModelProtocol::RegisteredProviderV1 => Err(
1858                "registered_provider_v1 requires a named model and uses the configured provider endpoint".into(),
1859            ),
1860        }
1861    }
1862}
1863
1864/// Main configuration structure for Bamboo agent
1865///
1866/// Contains all settings needed to run the agent, including provider credentials,
1867/// proxy settings, model selection, and server configuration.
1868#[derive(Debug, Clone, Serialize, Deserialize)]
1869#[doc(hidden)]
1870pub struct ConfigValues {
1871    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
1872    #[serde(default)]
1873    pub http_proxy: String,
1874    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
1875    #[serde(default)]
1876    pub https_proxy: String,
1877    /// Proxy authentication credentials
1878    ///
1879    /// Kept in memory only; ordinary config stores `proxy_auth_credential_ref`.
1880    #[serde(skip_serializing)]
1881    pub proxy_auth: Option<ProxyAuth>,
1882    /// Legacy encrypted proxy authentication accepted only for migration.
1883    #[serde(default, skip_serializing_if = "Option::is_none")]
1884    pub proxy_auth_encrypted: Option<String>,
1885    /// Stable credential-store reference for proxy authentication.
1886    #[serde(default, skip_serializing_if = "Option::is_none")]
1887    pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1888    /// Deprecated: Use `providers.copilot.headless_auth` instead
1889    #[serde(default)]
1890    pub headless_auth: bool,
1891
1892    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
1893    #[serde(default = "default_provider")]
1894    pub provider: String,
1895
1896    /// Default model assignments (used when features.provider_model_ref is enabled).
1897    #[serde(default, skip_serializing_if = "Option::is_none")]
1898    pub defaults: Option<DefaultsConfig>,
1899
1900    /// Multi-instance provider configurations keyed by instance id.
1901    ///
1902    /// When `provider_instances` is non-empty, the registry and router prefer
1903    /// instance ids as routing keys. Legacy `providers` / `provider` fields are
1904    /// still supported for backward compatibility; see
1905    /// [`Config::synthesize_legacy_instances`].
1906    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1907    pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1908
1909    /// The default provider instance id used when a request does not specify one.
1910    ///
1911    /// When set, this takes precedence over the legacy `provider` field.
1912    #[serde(default, skip_serializing_if = "Option::is_none")]
1913    pub default_provider_instance: Option<String>,
1914
1915    /// HTTP server configuration
1916    #[serde(default)]
1917    pub server: ServerConfig,
1918
1919    /// Global keyword masking configuration.
1920    ///
1921    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
1922    #[serde(default)]
1923    pub keyword_masking: KeywordMaskingConfig,
1924
1925    /// Anthropic model mapping configuration.
1926    ///
1927    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
1928    #[serde(default)]
1929    pub anthropic_model_mapping: AnthropicModelMapping,
1930
1931    /// Gemini model mapping configuration.
1932    ///
1933    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
1934    #[serde(default)]
1935    pub gemini_model_mapping: GeminiModelMapping,
1936
1937    /// Explicit model selection for offline semantic patrol input preparation.
1938    #[serde(default, skip_serializing_if = "Option::is_none")]
1939    pub patrol_model: Option<PatrolModelConfig>,
1940
1941    /// Request preflight hooks.
1942    ///
1943    /// These hooks can inspect and rewrite outgoing requests before they are sent upstream
1944    /// (e.g. image fallback behavior for text-only models).
1945    #[serde(default)]
1946    pub hooks: HooksConfig,
1947
1948    /// User-configured agent lifecycle command or external script handlers.
1949    ///
1950    /// This is intentionally separate from `hooks`, which is already the
1951    /// provider HTTP request-hook namespace. Lifecycle hooks are snapshotted
1952    /// when an agent run starts.
1953    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1954    pub lifecycle_hooks: LifecycleHooksConfig,
1955
1956    /// Global tool toggles.
1957    ///
1958    /// Any tool listed in `disabled` is omitted from the tool schemas sent to the LLM.
1959    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1960    pub tools: ToolsConfig,
1961
1962    /// Global skill toggles.
1963    ///
1964    /// Any skill listed in `disabled` is excluded from skill context construction and
1965    /// cannot be loaded through the skill runtime tools.
1966    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1967    pub skills: SkillsConfig,
1968
1969    /// User-managed environment variables injected into Bash tool processes.
1970    ///
1971    /// Secret entries are encrypted at rest; plaintext values are hydrated in memory.
1972    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1973    pub env_vars: Vec<EnvVarEntry>,
1974
1975    /// Default work area used when a session has no explicit active workspace.
1976    #[serde(default, skip_serializing_if = "Option::is_none")]
1977    pub default_work_area: Option<DefaultWorkAreaConfig>,
1978
1979    #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
1980    pub worktrees: WorktreeSettings,
1981
1982    /// Access control / password gate configuration.
1983    #[serde(default, skip_serializing_if = "Option::is_none")]
1984    pub access_control: Option<AccessControlConfig>,
1985
1986    /// Feature flags for incremental rollout.
1987    #[serde(default)]
1988    pub features: FeatureFlags,
1989
1990    /// Config-level default per-run token/tool-call/subagent budget (issue
1991    /// #221). `None` fields are unlimited. A per-request `ExecuteRequest`
1992    /// override may only tighten these ceilings, never loosen them; see
1993    /// [`RunBudgetConfig::merged_with_override`].
1994    #[serde(default)]
1995    pub run_budget: RunBudgetConfig,
1996
1997    /// LLM stream liveness and semantic-progress watchdog policy.
1998    #[serde(default)]
1999    pub stream_timeout: StreamTimeoutConfig,
2000
2001    /// Initial-request retry policy for transient transport failures and
2002    /// retryable upstream statuses. Operator-tunable so a flaky or
2003    /// heavily-queued relay can be tolerated without an environment variable
2004    /// and a process restart.
2005    ///
2006    /// `None` means "not configured": the environment-derived policy stays in
2007    /// force, so upgrading a deployment that tunes `BAMBOO_LLM_RETRY_*` does
2008    /// not silently replace those settings with the built-in defaults.
2009    #[serde(default, skip_serializing_if = "Option::is_none")]
2010    pub llm_retry: Option<LlmRetryConfig>,
2011
2012    /// Host-owned strategy for bounding provider-visible conversation context.
2013    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2014    pub context_management: ContextManagementConfig,
2015
2016    /// Remote Cluster Fabric: operator-managed nodes & clusters for deploying
2017    /// `broker-agent` workers locally or over SSH. Additive/back-compat: absent
2018    /// ⇒ empty. SSH secrets are encrypted at rest (see [`crate::cluster_fabric`]).
2019    #[serde(
2020        default,
2021        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2022    )]
2023    pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2024
2025    /// MCP server configuration.
2026    ///
2027    /// Previously persisted in `mcp.json` (now unified into `config.json`).
2028    // On disk we use the mainstream `mcpServers` key (matching Claude Desktop / MCP ecosystem
2029    // conventions). We still accept the legacy `mcp` key for backward compatibility.
2030    #[serde(default, rename = "mcpServers", alias = "mcp")]
2031    pub mcp: bamboo_domain::mcp_config::McpConfig,
2032
2033    /// Notification delivery channels (desktop + push-relay services).
2034    /// Secrets (ntfy token, Bark device key) live in the isolated credential
2035    /// store; only stable references/configured metadata persist here.
2036    #[serde(default)]
2037    pub notifications: NotificationsConfig,
2038
2039    /// bamboo-connect IM-platform bridges (Telegram first, #452 / epic #447).
2040    /// Secrets (each platform's `token`) are encrypted at rest — see
2041    /// [`Config::hydrate_connect_platform_tokens_from_encrypted`] /
2042    /// [`Config::refresh_connect_platform_tokens_encrypted`].
2043    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2044    pub connect: ConnectConfig,
2045
2046    /// Plugin URL-install source-trust policy (host allowlist + ed25519
2047    /// publisher keys). See [`PluginTrustConfig`]'s docs for the three-layer
2048    /// model this stacks with the checksum layer.
2049    #[serde(default)]
2050    pub plugin_trust: PluginTrustConfig,
2051
2052    /// Extension fields stored at the root of `config.json`.
2053    ///
2054    /// This keeps the config forward-compatible and allows unrelated subsystems
2055    /// (e.g. setup UI state) to persist their own keys without getting dropped by
2056    /// typed (de)serialization.
2057    #[serde(default, flatten)]
2058    pub extra: BTreeMap<String, Value>,
2059}
2060
2061impl Default for ConfigValues {
2062    fn default() -> Self {
2063        Self {
2064            http_proxy: String::new(),
2065            https_proxy: String::new(),
2066            proxy_auth: None,
2067            proxy_auth_encrypted: None,
2068            proxy_auth_credential_ref: None,
2069            headless_auth: false,
2070            run_budget: RunBudgetConfig::default(),
2071            stream_timeout: StreamTimeoutConfig::default(),
2072            llm_retry: None,
2073            context_management: ContextManagementConfig::default(),
2074            cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
2075            provider: default_provider(),
2076            provider_instances: HashMap::new(),
2077            default_provider_instance: None,
2078            server: ServerConfig::default(),
2079            keyword_masking: KeywordMaskingConfig::default(),
2080            anthropic_model_mapping: AnthropicModelMapping::default(),
2081            gemini_model_mapping: GeminiModelMapping::default(),
2082            patrol_model: None,
2083            hooks: HooksConfig::default(),
2084            lifecycle_hooks: LifecycleHooksConfig::default(),
2085            tools: ToolsConfig::default(),
2086            skills: SkillsConfig::default(),
2087            env_vars: Vec::new(),
2088            default_work_area: None,
2089            worktrees: WorktreeSettings::default(),
2090            access_control: None,
2091            features: FeatureFlags::default(),
2092            defaults: None,
2093            mcp: bamboo_domain::mcp_config::McpConfig::default(),
2094            notifications: NotificationsConfig::default(),
2095            connect: ConnectConfig::default(),
2096            plugin_trust: PluginTrustConfig::default(),
2097            extra: BTreeMap::new(),
2098        }
2099    }
2100}
2101
2102/// Network-facing root configuration. Flattening preserves the historical
2103/// top-level JSON keys while making the persisted root structurally modular.
2104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2105struct NetworkConfigSection {
2106    #[serde(default)]
2107    http_proxy: String,
2108    #[serde(default)]
2109    https_proxy: String,
2110    #[serde(skip_serializing)]
2111    proxy_auth: Option<ProxyAuth>,
2112    #[serde(default, skip_serializing_if = "Option::is_none")]
2113    proxy_auth_encrypted: Option<String>,
2114    #[serde(default, skip_serializing_if = "Option::is_none")]
2115    proxy_auth_credential_ref: Option<crate::CredentialRef>,
2116    #[serde(default)]
2117    headless_auth: bool,
2118    #[serde(default)]
2119    server: ServerConfig,
2120}
2121
2122#[derive(Debug, Clone, Serialize, Deserialize)]
2123struct ProviderRoutingConfigSection {
2124    #[serde(default = "default_provider")]
2125    provider: String,
2126    #[serde(default, skip_serializing_if = "Option::is_none")]
2127    defaults: Option<DefaultsConfig>,
2128    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2129    provider_instances: HashMap<String, ProviderInstanceConfig>,
2130    #[serde(default, skip_serializing_if = "Option::is_none")]
2131    default_provider_instance: Option<String>,
2132}
2133
2134impl Default for ProviderRoutingConfigSection {
2135    fn default() -> Self {
2136        Self {
2137            provider: default_provider(),
2138            defaults: None,
2139            provider_instances: HashMap::new(),
2140            default_provider_instance: None,
2141        }
2142    }
2143}
2144
2145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2146struct ModelBehaviorConfigSection {
2147    #[serde(default)]
2148    keyword_masking: KeywordMaskingConfig,
2149    #[serde(default)]
2150    anthropic_model_mapping: AnthropicModelMapping,
2151    #[serde(default)]
2152    gemini_model_mapping: GeminiModelMapping,
2153    #[serde(default, skip_serializing_if = "Option::is_none")]
2154    patrol_model: Option<PatrolModelConfig>,
2155    #[serde(default)]
2156    hooks: HooksConfig,
2157    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
2158    lifecycle_hooks: LifecycleHooksConfig,
2159}
2160
2161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2162struct ToolingConfigSection {
2163    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
2164    tools: ToolsConfig,
2165    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
2166    skills: SkillsConfig,
2167    #[serde(default, rename = "mcpServers", alias = "mcp")]
2168    mcp: bamboo_domain::mcp_config::McpConfig,
2169}
2170
2171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2172struct WorkspaceConfigSection {
2173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2174    env_vars: Vec<EnvVarEntry>,
2175    #[serde(default, skip_serializing_if = "Option::is_none")]
2176    default_work_area: Option<DefaultWorkAreaConfig>,
2177    #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
2178    worktrees: WorktreeSettings,
2179    #[serde(default, skip_serializing_if = "Option::is_none")]
2180    access_control: Option<AccessControlConfig>,
2181}
2182
2183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2184struct ExecutionConfigSection {
2185    #[serde(default)]
2186    features: FeatureFlags,
2187    #[serde(default)]
2188    run_budget: RunBudgetConfig,
2189    #[serde(default)]
2190    stream_timeout: StreamTimeoutConfig,
2191    #[serde(default, skip_serializing_if = "Option::is_none")]
2192    llm_retry: Option<LlmRetryConfig>,
2193    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2194    context_management: ContextManagementConfig,
2195    #[serde(
2196        default,
2197        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2198    )]
2199    cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2200}
2201
2202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2203struct IntegrationConfigSection {
2204    #[serde(default)]
2205    notifications: NotificationsConfig,
2206    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2207    connect: ConnectConfig,
2208}
2209
2210#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2211struct PluginSecurityConfigSection {
2212    #[serde(default)]
2213    plugin_trust: PluginTrustConfig,
2214}
2215
2216// Count declarations from the same field list that defines each structural
2217// budgeted type, so the constants cannot drift from the actual structs.
2218macro_rules! count_fields {
2219    ($($field:ident),* $(,)?) => {
2220        <[()]>::len(&[$(count_fields!(@one $field)),*])
2221    };
2222    (@one $field:ident) => { () };
2223}
2224
2225macro_rules! define_counted_struct {
2226    (
2227        $(#[$struct_meta:meta])*
2228        $visibility:vis struct $name:ident {
2229            $(
2230                $(#[$field_meta:meta])*
2231                $field_visibility:vis $field:ident: $field_type:ty
2232            ),* $(,)?
2233        }
2234        count $count_visibility:vis $count_name:ident;
2235    ) => {
2236        $(#[$struct_meta])*
2237        $visibility struct $name {
2238            $(
2239                $(#[$field_meta])*
2240                $field_visibility $field: $field_type,
2241            )*
2242        }
2243
2244        $count_visibility const $count_name: usize = count_fields!($($field),*);
2245    };
2246}
2247
2248define_counted_struct! {
2249    /// The root-only persistence DTO written to `config.json`.
2250    ///
2251    /// Every section is flattened so existing documents keep their historical
2252    /// top-level shape. The structural field count is nevertheless nine rather
2253    /// than the Phase-#39 baseline of 31.
2254    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
2255    struct ConfigRoot {
2256        #[serde(flatten)]
2257        network: NetworkConfigSection,
2258        #[serde(flatten)]
2259        provider_routing: ProviderRoutingConfigSection,
2260        #[serde(flatten)]
2261        model_behavior: ModelBehaviorConfigSection,
2262        #[serde(flatten)]
2263        tooling: ToolingConfigSection,
2264        #[serde(flatten)]
2265        workspace: WorkspaceConfigSection,
2266        #[serde(flatten)]
2267        execution: ExecutionConfigSection,
2268        #[serde(flatten)]
2269        integrations: IntegrationConfigSection,
2270        #[serde(flatten)]
2271        plugin_security: PluginSecurityConfigSection,
2272        #[serde(default, flatten)]
2273        extra: BTreeMap<String, Value>,
2274    }
2275    count pub PERSISTED_ROOT_FIELD_COUNT;
2276}
2277
2278impl From<ConfigValues> for ConfigRoot {
2279    fn from(values: ConfigValues) -> Self {
2280        // Deliberately exhaustive: adding a runtime compatibility field must
2281        // update its persisted section mapping or this conversion stops compiling.
2282        let ConfigValues {
2283            http_proxy,
2284            https_proxy,
2285            proxy_auth,
2286            proxy_auth_encrypted,
2287            proxy_auth_credential_ref,
2288            headless_auth,
2289            provider,
2290            defaults,
2291            provider_instances,
2292            default_provider_instance,
2293            server,
2294            keyword_masking,
2295            anthropic_model_mapping,
2296            gemini_model_mapping,
2297            patrol_model,
2298            hooks,
2299            lifecycle_hooks,
2300            tools,
2301            skills,
2302            env_vars,
2303            default_work_area,
2304            worktrees,
2305            access_control,
2306            features,
2307            run_budget,
2308            stream_timeout,
2309            llm_retry,
2310            context_management,
2311            cluster_fabric,
2312            mcp,
2313            notifications,
2314            connect,
2315            plugin_trust,
2316            extra,
2317        } = values;
2318
2319        Self {
2320            network: NetworkConfigSection {
2321                http_proxy,
2322                https_proxy,
2323                proxy_auth,
2324                proxy_auth_encrypted,
2325                proxy_auth_credential_ref,
2326                headless_auth,
2327                server,
2328            },
2329            provider_routing: ProviderRoutingConfigSection {
2330                provider,
2331                defaults,
2332                provider_instances,
2333                default_provider_instance,
2334            },
2335            model_behavior: ModelBehaviorConfigSection {
2336                keyword_masking,
2337                anthropic_model_mapping,
2338                gemini_model_mapping,
2339                patrol_model,
2340                hooks,
2341                lifecycle_hooks,
2342            },
2343            tooling: ToolingConfigSection { tools, skills, mcp },
2344            workspace: WorkspaceConfigSection {
2345                env_vars,
2346                default_work_area,
2347                worktrees,
2348                access_control,
2349            },
2350            execution: ExecutionConfigSection {
2351                features,
2352                run_budget,
2353                stream_timeout,
2354                llm_retry,
2355                context_management,
2356                cluster_fabric,
2357            },
2358            integrations: IntegrationConfigSection {
2359                notifications,
2360                connect,
2361            },
2362            plugin_security: PluginSecurityConfigSection { plugin_trust },
2363            extra,
2364        }
2365    }
2366}
2367
2368impl From<ConfigRoot> for ConfigValues {
2369    fn from(root: ConfigRoot) -> Self {
2370        // Keep every root and section destructure exhaustive so a newly added
2371        // persisted field cannot be silently omitted from the runtime view.
2372        let ConfigRoot {
2373            network,
2374            provider_routing,
2375            model_behavior,
2376            tooling,
2377            workspace,
2378            execution,
2379            integrations,
2380            plugin_security,
2381            extra,
2382        } = root;
2383        let NetworkConfigSection {
2384            http_proxy,
2385            https_proxy,
2386            proxy_auth,
2387            proxy_auth_encrypted,
2388            proxy_auth_credential_ref,
2389            headless_auth,
2390            server,
2391        } = network;
2392        let ProviderRoutingConfigSection {
2393            provider,
2394            defaults,
2395            provider_instances,
2396            default_provider_instance,
2397        } = provider_routing;
2398        let ModelBehaviorConfigSection {
2399            keyword_masking,
2400            anthropic_model_mapping,
2401            gemini_model_mapping,
2402            patrol_model,
2403            hooks,
2404            lifecycle_hooks,
2405        } = model_behavior;
2406        let ToolingConfigSection { tools, skills, mcp } = tooling;
2407        let WorkspaceConfigSection {
2408            env_vars,
2409            default_work_area,
2410            worktrees,
2411            access_control,
2412        } = workspace;
2413        let ExecutionConfigSection {
2414            features,
2415            run_budget,
2416            stream_timeout,
2417            llm_retry,
2418            context_management,
2419            cluster_fabric,
2420        } = execution;
2421        let IntegrationConfigSection {
2422            notifications,
2423            connect,
2424        } = integrations;
2425        let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2426
2427        Self {
2428            http_proxy,
2429            https_proxy,
2430            proxy_auth,
2431            proxy_auth_encrypted,
2432            proxy_auth_credential_ref,
2433            headless_auth,
2434            provider,
2435            defaults,
2436            provider_instances,
2437            default_provider_instance,
2438            server,
2439            keyword_masking,
2440            anthropic_model_mapping,
2441            gemini_model_mapping,
2442            patrol_model,
2443            hooks,
2444            lifecycle_hooks,
2445            tools,
2446            skills,
2447            env_vars,
2448            default_work_area,
2449            worktrees,
2450            access_control,
2451            features,
2452            run_budget,
2453            stream_timeout,
2454            llm_retry,
2455            context_management,
2456            cluster_fabric,
2457            mcp,
2458            notifications,
2459            connect,
2460            plugin_trust,
2461            extra,
2462        }
2463    }
2464}
2465
2466/// Serialize the root-only durable document.
2467///
2468/// Public `Config` serialization intentionally retains the historical
2469/// compatibility shape, including the legacy `provider` selector. Durable
2470/// instance-native writes are narrower: the explicit instance default is the
2471/// routing authority, so legacy routing fields must not be written back.
2472fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2473    let instance_native = values
2474        .default_provider_instance
2475        .as_ref()
2476        .is_some_and(|id| values.provider_instances.contains_key(id));
2477    let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2478    if instance_native {
2479        if let Some(object) = value.as_object_mut() {
2480            object.remove("provider");
2481            object.remove("providers");
2482        }
2483    }
2484    Ok(value)
2485}
2486
2487define_counted_struct! {
2488    /// Runtime configuration facade. Phase-1 sidecar domains are typed modules;
2489    /// the remaining values keep field-access compatibility through `Deref`.
2490    #[derive(Debug, Clone)]
2491    pub struct Config {
2492        values: ConfigValues,
2493        pub(crate) memory: crate::MemoryConfigModule,
2494        pub(crate) subagents: crate::SubagentsConfigModule,
2495        pub(crate) providers: crate::ProviderConfigsModule,
2496        recovery_status: Option<ConfigRecoveryStatus>,
2497    }
2498    count pub CONFIG_FIELD_COUNT;
2499}
2500
2501/// Auditable structural budgets from Issue #590.
2502pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2503const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2504const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2505
2506impl std::ops::Deref for Config {
2507    type Target = ConfigValues;
2508
2509    fn deref(&self) -> &Self::Target {
2510        &self.values
2511    }
2512}
2513
2514impl std::ops::DerefMut for Config {
2515    fn deref_mut(&mut self) -> &mut Self::Target {
2516        &mut self.values
2517    }
2518}
2519
2520impl Serialize for Config {
2521    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2522    where
2523        S: serde::Serializer,
2524    {
2525        self.to_compatibility_value()
2526            .map_err(serde::ser::Error::custom)?
2527            .serialize(serializer)
2528    }
2529}
2530
2531impl<'de> Deserialize<'de> for Config {
2532    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2533    where
2534        D: serde::Deserializer<'de>,
2535    {
2536        use serde::de::Error;
2537
2538        let mut value = Value::deserialize(deserializer)?;
2539        let object = value
2540            .as_object_mut()
2541            .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2542        let memory = object
2543            .remove("memory")
2544            .map(serde_json::from_value)
2545            .transpose()
2546            .map_err(D::Error::custom)?
2547            .unwrap_or_default();
2548        let subagents = object
2549            .remove("subagents")
2550            .map(serde_json::from_value)
2551            .transpose()
2552            .map_err(D::Error::custom)?
2553            .unwrap_or_default();
2554        let providers = object
2555            .remove("providers")
2556            .map(serde_json::from_value)
2557            .transpose()
2558            .map_err(D::Error::custom)?
2559            .unwrap_or_default();
2560        let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2561        if let Some(model) = &root.model_behavior.patrol_model {
2562            model.validate().map_err(D::Error::custom)?;
2563        }
2564
2565        Ok(Self::from_parts(root.into(), memory, subagents, providers))
2566    }
2567}
2568
2569/// Where a [`ConfigRecoveryStatus`]'s recovered values came from. #153.
2570#[derive(Debug, Clone, PartialEq, Serialize)]
2571#[serde(tag = "kind", rename_all = "snake_case")]
2572pub enum ConfigRecoverySource {
2573    /// Field-by-field salvage from the corrupt file itself
2574    /// ([`Config::salvage_partial`]); `fields` lists the top-level keys that
2575    /// were recovered from the corrupt document (any other field fell back to
2576    /// the backup/default baseline instead).
2577    Salvaged { fields: Vec<String> },
2578    /// Recovered wholesale from a `config.json.bak[.N]` generation
2579    /// (`generation` 0 == `.bak`, 1 == `.bak.1`, …).
2580    Backup { generation: usize },
2581    /// No usable salvage or backup; fell back to built-in defaults.
2582    Defaults,
2583}
2584
2585/// Describes a pending config-corruption recovery (#153, following on from
2586/// #37/#135's quarantine + salvage/backup chain): `config.json` failed to
2587/// parse at load time, the corrupt original was quarantined (copied aside,
2588/// not deleted) to `quarantine_path`, and the owning [`Config`] holds the
2589/// recovered in-memory state instead.
2590///
2591/// [`Config::save_to_dir`] refuses to overwrite `config.json` while
2592/// `confirmed` is `false`, so a user who would rather hand-fix the original
2593/// isn't surprised by an automatic overwrite on the next save. Call
2594/// [`Config::confirm_recovery`] (or [`Config::confirm_recovery_and_save_to_dir`])
2595/// to allow the next save through.
2596#[derive(Debug, Clone, PartialEq, Serialize)]
2597pub struct ConfigRecoveryStatus {
2598    /// Where the recovered values came from.
2599    pub source: ConfigRecoverySource,
2600    /// Absolute path of the preserved copy of the corrupt original
2601    /// (`config.json.corrupted.<nanos>`), or `None` if even the quarantine
2602    /// copy failed (the corrupt original still remains in place at
2603    /// `config.json` itself either way — quarantining copies, it doesn't
2604    /// move — so the guard below still applies).
2605    pub quarantine_path: Option<PathBuf>,
2606    /// Set `true` once the user has explicitly confirmed the recovery; only
2607    /// then may `save_to_dir` persist over the original `config.json`.
2608    pub confirmed: bool,
2609}
2610
2611/// Container for provider-specific configurations
2612///
2613/// Each field is optional, allowing users to configure only the providers they need.
2614#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2615pub struct ProviderConfigs {
2616    /// OpenAI provider configuration
2617    #[serde(skip_serializing_if = "Option::is_none")]
2618    pub openai: Option<OpenAIConfig>,
2619    /// Anthropic provider configuration
2620    #[serde(skip_serializing_if = "Option::is_none")]
2621    pub anthropic: Option<AnthropicConfig>,
2622    /// Google Gemini provider configuration
2623    #[serde(skip_serializing_if = "Option::is_none")]
2624    pub gemini: Option<GeminiConfig>,
2625    /// GitHub Copilot provider configuration
2626    #[serde(skip_serializing_if = "Option::is_none")]
2627    pub copilot: Option<CopilotConfig>,
2628    /// Bodhi proxy provider configuration
2629    #[serde(skip_serializing_if = "Option::is_none")]
2630    pub bodhi: Option<BodhiConfig>,
2631
2632    /// Preserve unknown provider keys (forward compatibility).
2633    #[serde(default, flatten)]
2634    pub extra: BTreeMap<String, Value>,
2635}
2636
2637impl ProviderConfigs {
2638    /// Remove only the known legacy selector and built-in aliases while
2639    /// preserving unknown forward-compatible provider entries in `extra`.
2640    pub fn clear_legacy_builtin_aliases(&mut self) {
2641        self.openai = None;
2642        self.anthropic = None;
2643        self.gemini = None;
2644        self.copilot = None;
2645        self.bodhi = None;
2646        self.extra.remove("provider");
2647    }
2648}
2649
2650/// Feature flags for incremental rollout of new subsystems.
2651#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2652pub struct FeatureFlags {
2653    /// File-authoritative Tickets for a newly bootstrapped Supervisor only.
2654    #[serde(default)]
2655    pub ticket_mutation: bool,
2656    /// Actual fresh one-shot dispatch; independent of Ticket record writes.
2657    #[serde(default)]
2658    pub ticket_dispatch: bool,
2659    /// Enable the ProviderModelRef system (multi-provider + unified model selection).
2660    #[serde(default)]
2661    pub provider_model_ref: bool,
2662    /// Enable MiniLoop-based complexity evaluation and dynamic per-round model switching.
2663    #[serde(default)]
2664    pub dynamic_model_routing: bool,
2665    /// Check a final answer once against this request's recorded tool evidence.
2666    #[serde(default)]
2667    pub final_evidence_check: bool,
2668}
2669
2670/// Default model assignments for specific capabilities.
2671///
2672/// Used when `features.provider_model_ref` is enabled.
2673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2674pub struct DefaultsConfig {
2675    pub chat: bamboo_domain::ProviderModelRef,
2676    #[serde(default, skip_serializing_if = "Option::is_none")]
2677    pub fast: Option<bamboo_domain::ProviderModelRef>,
2678    #[serde(default, skip_serializing_if = "Option::is_none")]
2679    pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2680    #[serde(default, skip_serializing_if = "Option::is_none")]
2681    pub vision: Option<bamboo_domain::ProviderModelRef>,
2682    #[serde(default, skip_serializing_if = "Option::is_none")]
2683    pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2684    /// Model for planning/coordination tasks (task decomposition, architecture).
2685    /// Falls back to `fast`, then `chat`, when unset.
2686    #[serde(default, skip_serializing_if = "Option::is_none")]
2687    pub planning: Option<bamboo_domain::ProviderModelRef>,
2688    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
2689    /// Falls back to `fast`, then `chat`, when unset.
2690    #[serde(default, skip_serializing_if = "Option::is_none")]
2691    pub search: Option<bamboo_domain::ProviderModelRef>,
2692    /// Model for code review tasks.
2693    /// Falls back to `fast`, then `chat`, when unset.
2694    #[serde(default, skip_serializing_if = "Option::is_none")]
2695    pub code_review: Option<bamboo_domain::ProviderModelRef>,
2696    /// Model for reviewing Work records. An absent role uses the configured Fast model.
2697    #[serde(default, skip_serializing_if = "Option::is_none")]
2698    pub work_review: Option<bamboo_domain::ProviderModelRef>,
2699    /// Default model for child SubAgent runs.
2700    /// Falls back to `fast`, then `chat` when unset.
2701    #[serde(
2702        default,
2703        skip_serializing_if = "Option::is_none",
2704        alias = "sub_session"
2705    )]
2706    pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2707    /// Per-subagent-type model overrides.
2708    /// Key = subagent_type (e.g. "researcher", "coder"), Value = ProviderModelRef.
2709    /// Falls back to `sub_agent`, then `fast`, then `chat` when no match is found.
2710    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2711    pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2712}
2713
2714/// Request hook configuration.
2715#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2716pub struct HooksConfig {
2717    /// Image fallback behavior for OpenAI-compatible requests (chat/responses).
2718    #[serde(default)]
2719    pub image_fallback: ImageFallbackHookConfig,
2720}
2721
2722/// Default deadline for one lifecycle command hook.
2723pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2724/// Smallest accepted lifecycle hook deadline at the config API boundary.
2725pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2726/// Largest accepted lifecycle hook deadline (10 minutes). Lifecycle hooks run
2727/// inline with agent progress, so they share the same upper bound as Bamboo's
2728/// interactive shell tool instead of allowing an accidental hours-long stall.
2729pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2730
2731/// Stable user-facing event keys accepted by `lifecycle_hooks`.
2732pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 9] = [
2733    "SessionStart",
2734    "UserPromptSubmit",
2735    "PreToolUse",
2736    "PostToolUse",
2737    "Stop",
2738    "SessionEnd",
2739    "PreCompact",
2740    "Notification",
2741    "BambooModelCallUsage",
2742];
2743
2744fn default_lifecycle_hook_timeout_ms() -> u64 {
2745    DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2746}
2747
2748fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2749    *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2750}
2751
2752fn lifecycle_hook_enabled_default() -> bool {
2753    true
2754}
2755
2756fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2757    *value
2758}
2759
2760/// Config-driven agent lifecycle hooks.
2761///
2762/// Event names deliberately preserve the user-facing PascalCase protocol.
2763/// Server-owned events use the same stable schema as engine-owned events.
2764#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2765pub struct LifecycleHooksConfig {
2766    #[serde(default)]
2767    pub enabled: bool,
2768    #[serde(
2769        default,
2770        rename = "SessionStart",
2771        skip_serializing_if = "Vec::is_empty"
2772    )]
2773    pub session_start: Vec<LifecycleHookGroup>,
2774    #[serde(
2775        default,
2776        rename = "UserPromptSubmit",
2777        skip_serializing_if = "Vec::is_empty"
2778    )]
2779    pub user_prompt_submit: Vec<LifecycleHookGroup>,
2780    #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2781    pub pre_tool_use: Vec<LifecycleHookGroup>,
2782    #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2783    pub post_tool_use: Vec<LifecycleHookGroup>,
2784    #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2785    pub stop: Vec<LifecycleHookGroup>,
2786    #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2787    pub session_end: Vec<LifecycleHookGroup>,
2788    #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2789    pub pre_compact: Vec<LifecycleHookGroup>,
2790    #[serde(
2791        default,
2792        rename = "Notification",
2793        skip_serializing_if = "Vec::is_empty"
2794    )]
2795    pub notification: Vec<LifecycleHookGroup>,
2796    #[serde(
2797        default,
2798        rename = "BambooModelCallUsage",
2799        skip_serializing_if = "Vec::is_empty"
2800    )]
2801    pub model_call_usage: Vec<LifecycleHookGroup>,
2802}
2803
2804impl LifecycleHooksConfig {
2805    pub fn is_empty(&self) -> bool {
2806        !self.enabled
2807            && self.session_start.is_empty()
2808            && self.user_prompt_submit.is_empty()
2809            && self.pre_tool_use.is_empty()
2810            && self.post_tool_use.is_empty()
2811            && self.stop.is_empty()
2812            && self.session_end.is_empty()
2813            && self.pre_compact.is_empty()
2814            && self.notification.is_empty()
2815            && self.model_call_usage.is_empty()
2816    }
2817}
2818
2819/// A matcher and its ordered handler list for one lifecycle event.
2820#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2821pub struct LifecycleHookGroup {
2822    /// A disabled group remains persisted and editable but is not registered
2823    /// for execution. Missing values default to true for old config files.
2824    #[serde(
2825        default = "lifecycle_hook_enabled_default",
2826        skip_serializing_if = "lifecycle_hook_enabled_is_default"
2827    )]
2828    pub enabled: bool,
2829    #[serde(default, skip_serializing_if = "Option::is_none")]
2830    pub matcher: Option<String>,
2831    #[serde(default)]
2832    pub hooks: Vec<LifecycleHookHandler>,
2833}
2834
2835impl Default for LifecycleHookGroup {
2836    fn default() -> Self {
2837        Self {
2838            enabled: true,
2839            matcher: None,
2840            hooks: Vec::new(),
2841        }
2842    }
2843}
2844
2845/// One configured lifecycle hook handler.
2846///
2847/// The internally tagged representation preserves the existing command JSON
2848/// while allowing handler-specific validation for external scripts.
2849#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2850#[serde(tag = "type", rename_all = "lowercase")]
2851pub enum LifecycleHookHandler {
2852    Command {
2853        command: String,
2854        #[serde(
2855            default = "default_lifecycle_hook_timeout_ms",
2856            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2857        )]
2858        timeout_ms: u64,
2859    },
2860    Script {
2861        path: String,
2862        #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2863        runner: LifecycleScriptRunner,
2864        #[serde(
2865            default = "default_lifecycle_hook_timeout_ms",
2866            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2867        )]
2868        timeout_ms: u64,
2869    },
2870}
2871
2872/// Runtime used to execute a lifecycle script.
2873///
2874/// `auto` infers the language from the file extension and tries the system
2875/// runtimes in a deterministic order. Explicit runners are useful when both
2876/// Node.js and Bun are installed or when a deployment standardizes one binary.
2877#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2878#[serde(rename_all = "lowercase")]
2879pub enum LifecycleScriptRunner {
2880    #[default]
2881    Auto,
2882    Node,
2883    Bun,
2884    Python,
2885    Bash,
2886    PowerShell,
2887    Cmd,
2888}
2889
2890impl LifecycleScriptRunner {
2891    pub fn is_auto(&self) -> bool {
2892        matches!(self, Self::Auto)
2893    }
2894
2895    pub fn as_str(self) -> &'static str {
2896        match self {
2897            Self::Auto => "auto",
2898            Self::Node => "node",
2899            Self::Bun => "bun",
2900            Self::Python => "python",
2901            Self::Bash => "bash",
2902            Self::PowerShell => "powershell",
2903            Self::Cmd => "cmd",
2904        }
2905    }
2906
2907    /// Whether this runner can execute the supplied supported script path.
2908    pub fn supports_path(self, path: &str) -> bool {
2909        let extension = lifecycle_script_extension(path);
2910        match self {
2911            Self::Auto => extension.is_some(),
2912            Self::Node | Self::Bun => {
2913                matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2914            }
2915            Self::Python => matches!(extension.as_deref(), Some("py")),
2916            Self::Bash => matches!(extension.as_deref(), Some("sh")),
2917            Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2918            Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2919        }
2920    }
2921}
2922
2923/// Return the normalized extension when the path names a supported lifecycle
2924/// script.
2925pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2926    let extension = std::path::Path::new(path)
2927        .extension()?
2928        .to_str()?
2929        .to_ascii_lowercase();
2930    matches!(
2931        extension.as_str(),
2932        "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2933    )
2934    .then_some(extension)
2935}
2936
2937impl LifecycleHookHandler {
2938    pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2939        Self::Command {
2940            command: command.into(),
2941            timeout_ms,
2942        }
2943    }
2944
2945    pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2946        Self::Script {
2947            path: path.into(),
2948            runner,
2949            timeout_ms,
2950        }
2951    }
2952
2953    pub fn timeout_ms(&self) -> u64 {
2954        match self {
2955            Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2956        }
2957    }
2958}
2959
2960/// Request override configuration for provider-specific HTTP behavior.
2961///
2962/// Overrides are merged in this order (later wins):
2963/// 1. `common`
2964/// 2. `endpoints[endpoint]`
2965/// 3. matching `rules` (sorted by specificity)
2966#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2967pub struct RequestOverridesConfig {
2968    /// Overrides applied to all endpoints.
2969    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2970    pub common: RequestScopeOverride,
2971    /// Endpoint-specific overrides (`chat_completions`, `responses`, `messages`, etc.).
2972    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2973    pub endpoints: BTreeMap<String, RequestScopeOverride>,
2974    /// Model-conditional overrides.
2975    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2976    pub rules: Vec<ModelRequestRule>,
2977}
2978
2979/// A conditional override rule matching a model pattern.
2980#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2981pub struct ModelRequestRule {
2982    /// Model pattern (exact: `gpt-4o`, prefix wildcard: `gpt-5*`).
2983    pub model_pattern: String,
2984    /// Optional endpoint constraint.
2985    #[serde(default, skip_serializing_if = "Option::is_none")]
2986    pub endpoint: Option<String>,
2987    /// Overrides applied when this rule matches.
2988    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2989    pub scope: RequestScopeOverride,
2990}
2991
2992/// Request overrides applied in a specific scope.
2993#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2994pub struct RequestScopeOverride {
2995    /// Extra or overridden HTTP headers.
2996    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2997    pub headers: BTreeMap<String, TemplateExpr>,
2998    /// JSON body patch operations.
2999    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3000    pub body_patch: Vec<BodyPatch>,
3001}
3002
3003impl RequestScopeOverride {
3004    pub fn is_empty(&self) -> bool {
3005        self.headers.is_empty() && self.body_patch.is_empty()
3006    }
3007}
3008
3009/// Body patch operation.
3010#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3011pub struct BodyPatch {
3012    /// Target path (`foo.bar.0` or `/foo/bar/0`).
3013    pub path: String,
3014    /// Operation type.
3015    #[serde(default)]
3016    pub op: BodyPatchOp,
3017    /// Value for `set` operation.
3018    #[serde(default, skip_serializing_if = "Option::is_none")]
3019    pub value: Option<PatchValue>,
3020}
3021
3022/// Supported body patch operations.
3023#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
3024#[serde(rename_all = "snake_case")]
3025pub enum BodyPatchOp {
3026    #[default]
3027    Set,
3028    Remove,
3029}
3030
3031/// Body patch value: either a template expression or a raw JSON value.
3032#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3033#[serde(untagged)]
3034pub enum PatchValue {
3035    Template(TemplateExpr),
3036    Json(Value),
3037}
3038
3039/// String template expression used by headers/body patch values.
3040#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3041#[serde(untagged)]
3042pub enum TemplateExpr {
3043    /// Shorthand literal value.
3044    Literal(String),
3045    /// Structured template expression.
3046    Structured(TemplateExprSpec),
3047}
3048
3049/// Structured template expression.
3050#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3051#[serde(tag = "type", rename_all = "snake_case")]
3052pub enum TemplateExprSpec {
3053    /// Literal string value.
3054    Literal { value: String },
3055    /// Reference a value from Bamboo env vars.
3056    EnvRef {
3057        name: String,
3058        #[serde(default, skip_serializing_if = "Option::is_none")]
3059        fallback: Option<String>,
3060    },
3061    /// Generate a runtime value.
3062    Generated { generator: GeneratedValue },
3063    /// Format string with placeholders (`{env:NAME}`, `{uuid}`, `{unix_ms}`).
3064    Format { template: String },
3065}
3066
3067/// Supported generated value kinds.
3068#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3069#[serde(rename_all = "snake_case")]
3070pub enum GeneratedValue {
3071    Uuid,
3072    UnixMs,
3073}
3074
3075/// Global tool toggle configuration.
3076#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3077pub struct ToolsConfig {
3078    /// Tool names that are disabled globally.
3079    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3080    pub disabled: Vec<String>,
3081
3082    /// Preserve tool configuration owned by newer Bamboo versions or plugins.
3083    #[serde(default, flatten)]
3084    pub extra: BTreeMap<String, Value>,
3085}
3086
3087impl ToolsConfig {
3088    fn is_empty(&self) -> bool {
3089        self.disabled.is_empty() && self.extra.is_empty()
3090    }
3091}
3092
3093/// Global skill toggle configuration.
3094#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3095pub struct SkillsConfig {
3096    /// Skill IDs that are disabled globally.
3097    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3098    pub disabled: Vec<String>,
3099
3100    /// Preserve skill configuration owned by newer Bamboo versions or plugins.
3101    #[serde(default, flatten)]
3102    pub extra: BTreeMap<String, Value>,
3103}
3104
3105impl SkillsConfig {
3106    fn is_empty(&self) -> bool {
3107        self.disabled.is_empty() && self.extra.is_empty()
3108    }
3109}
3110
3111/// When a request contains image parts but the effective provider path is text-only,
3112/// we can either:
3113/// - error fast (preferred for strict setups), or
3114/// - degrade gracefully by replacing images with a placeholder text.
3115#[derive(Debug, Clone, Serialize, Deserialize)]
3116pub struct ImageFallbackHookConfig {
3117    #[serde(default = "default_true_hooks")]
3118    pub enabled: bool,
3119
3120    /// "placeholder" (default) or "error"
3121    #[serde(default = "default_image_fallback_mode")]
3122    pub mode: String,
3123}
3124
3125impl Default for ImageFallbackHookConfig {
3126    fn default() -> Self {
3127        Self {
3128            enabled: default_true_hooks(),
3129            mode: default_image_fallback_mode(),
3130        }
3131    }
3132}
3133
3134fn default_image_fallback_mode() -> String {
3135    "placeholder".to_string()
3136}
3137
3138fn default_true_hooks() -> bool {
3139    // Default to disabled so image inputs are preserved unless the user explicitly
3140    // opts into fallback rewriting (placeholder/error/ocr).
3141    false
3142}
3143
3144/// OpenAI provider configuration
3145///
3146/// # Example
3147///
3148/// ```json
3149/// "openai": {
3150///   "api_key": "sk-...",
3151///   "base_url": "https://api.openai.com/v1",
3152///   "model": "gpt-4"
3153/// }
3154/// ```
3155pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
3156
3157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3158pub struct OpenAIConfig {
3159    /// OpenAI API key (plaintext, in-memory only).
3160    ///
3161    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3162    #[serde(default, skip_serializing)]
3163    pub api_key: String,
3164    /// Encrypted OpenAI API key (nonce:ciphertext).
3165    #[serde(default, skip_serializing_if = "Option::is_none")]
3166    pub api_key_encrypted: Option<String>,
3167    /// Stable reference to the isolated credential store.
3168    #[serde(default, skip_serializing_if = "Option::is_none")]
3169    pub credential_ref: Option<crate::CredentialRef>,
3170    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3171    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3172    /// `api_key_encrypted` on save (that would bake the secret into
3173    /// config.json). Not (de)serialized. (#253)
3174    #[serde(skip)]
3175    pub api_key_from_env: bool,
3176    /// Custom API base URL (for Azure or self-hosted deployments)
3177    #[serde(skip_serializing_if = "Option::is_none")]
3178    pub base_url: Option<String>,
3179    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
3180    #[serde(skip_serializing_if = "Option::is_none")]
3181    pub model: Option<String>,
3182    /// Fast/cheap model for lightweight tasks (title generation and summarization).
3183    /// Falls back to `model` when not set.
3184    #[serde(default, skip_serializing_if = "Option::is_none")]
3185    pub fast_model: Option<String>,
3186    /// Vision-capable model for image understanding tasks.
3187    /// Falls back to `model` when not set.
3188    #[serde(default, skip_serializing_if = "Option::is_none")]
3189    pub vision_model: Option<String>,
3190    /// Default reasoning effort for OpenAI requests.
3191    #[serde(skip_serializing_if = "Option::is_none")]
3192    pub reasoning_effort: Option<ReasoningEffort>,
3193
3194    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3195    ///
3196    /// Example:
3197    /// ```json
3198    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3199    /// ```
3200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3201    pub responses_only_models: Vec<String>,
3202    /// Optional request overrides (headers/body patches/model rules).
3203    #[serde(default, skip_serializing_if = "Option::is_none")]
3204    pub request_overrides: Option<RequestOverridesConfig>,
3205
3206    /// Preserve unknown keys under `providers.openai`.
3207    #[serde(default, flatten)]
3208    pub extra: BTreeMap<String, Value>,
3209}
3210
3211impl OpenAIConfig {
3212    /// Whether Bamboo may lower its provider-neutral cache plan into GPT-5.6+
3213    /// `prompt_cache_options` and `prompt_cache_breakpoint` fields.
3214    ///
3215    /// This defaults to enabled. OpenAI-compatible upstreams that have not yet
3216    /// implemented the explicit-cache request fields can opt out without
3217    /// disabling `prompt_cache_key` or the upstream's implicit prompt cache.
3218    pub fn explicit_prompt_cache_enabled(&self) -> bool {
3219        self.extra
3220            .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3221            .and_then(Value::as_bool)
3222            .unwrap_or(true)
3223    }
3224}
3225
3226/// Anthropic provider configuration
3227///
3228/// # Example
3229///
3230/// ```json
3231/// "anthropic": {
3232///   "api_key": "sk-ant-...",
3233///   "model": "claude-3-5-sonnet-20241022",
3234///   "max_tokens": 4096
3235/// }
3236/// ```
3237#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3238pub struct AnthropicConfig {
3239    /// Anthropic API key (plaintext, in-memory only).
3240    ///
3241    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3242    #[serde(default, skip_serializing)]
3243    pub api_key: String,
3244    /// Encrypted Anthropic API key (nonce:ciphertext).
3245    #[serde(default, skip_serializing_if = "Option::is_none")]
3246    pub api_key_encrypted: Option<String>,
3247    /// Stable reference to the isolated credential store.
3248    #[serde(default, skip_serializing_if = "Option::is_none")]
3249    pub credential_ref: Option<crate::CredentialRef>,
3250    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3251    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3252    /// `api_key_encrypted` on save (that would bake the secret into
3253    /// config.json). Not (de)serialized. (#253)
3254    #[serde(skip)]
3255    pub api_key_from_env: bool,
3256    /// Custom API base URL
3257    #[serde(skip_serializing_if = "Option::is_none")]
3258    pub base_url: Option<String>,
3259    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
3260    #[serde(skip_serializing_if = "Option::is_none")]
3261    pub model: Option<String>,
3262    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3263    /// Falls back to `model` when not set.
3264    #[serde(default, skip_serializing_if = "Option::is_none")]
3265    pub fast_model: Option<String>,
3266    /// Vision-capable model for image understanding tasks.
3267    /// Falls back to `model` when not set.
3268    #[serde(default, skip_serializing_if = "Option::is_none")]
3269    pub vision_model: Option<String>,
3270    /// Maximum tokens in model response
3271    #[serde(skip_serializing_if = "Option::is_none")]
3272    pub max_tokens: Option<u32>,
3273    /// Default reasoning effort for Anthropic requests.
3274    #[serde(skip_serializing_if = "Option::is_none")]
3275    pub reasoning_effort: Option<ReasoningEffort>,
3276    /// Optional request overrides (headers/body patches/model rules).
3277    #[serde(default, skip_serializing_if = "Option::is_none")]
3278    pub request_overrides: Option<RequestOverridesConfig>,
3279
3280    /// Unconditionally replay a prior turn's `reasoning` as a `thinking`
3281    /// content block, regardless of whether bamboo captured a valid signature
3282    /// for it (issue #520).
3283    ///
3284    /// Defaults to `false`/absent, which is REQUIRED for real Anthropic: it
3285    /// requires `thinking` input blocks to carry a signature it minted itself,
3286    /// and bamboo never captures one, so an unconditionally-replayed block is
3287    /// always rejected with a 400 (either because it's foreign — minted by a
3288    /// different provider after a mid-session model switch — or because it's
3289    /// an unsigned copy of Claude's own prior turn).
3290    ///
3291    /// Set this to `true` only when pointing `base_url` at an
3292    /// Anthropic-COMPATIBLE upstream (e.g. GLM's `/anthropic` endpoint) that
3293    /// has the opposite contract: it requires the `thinking` block to be
3294    /// present whenever thinking is enabled, but never validates its
3295    /// signature.
3296    #[serde(default, skip_serializing_if = "Option::is_none")]
3297    pub thinking_replay_always: Option<bool>,
3298
3299    /// Preserve unknown keys under `providers.anthropic`.
3300    #[serde(default, flatten)]
3301    pub extra: BTreeMap<String, Value>,
3302}
3303
3304/// Google Gemini provider configuration
3305///
3306/// # Example
3307///
3308/// ```json
3309/// "gemini": {
3310///   "api_key": "AIza...",
3311///   "model": "gemini-2.0-flash-exp"
3312/// }
3313/// ```
3314#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3315pub struct GeminiConfig {
3316    /// Google AI API key (plaintext, in-memory only).
3317    ///
3318    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3319    #[serde(default, skip_serializing)]
3320    pub api_key: String,
3321    /// Encrypted Google AI API key (nonce:ciphertext).
3322    #[serde(default, skip_serializing_if = "Option::is_none")]
3323    pub api_key_encrypted: Option<String>,
3324    /// Stable reference to the isolated credential store.
3325    #[serde(default, skip_serializing_if = "Option::is_none")]
3326    pub credential_ref: Option<crate::CredentialRef>,
3327    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3328    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3329    /// `api_key_encrypted` on save (that would bake the secret into
3330    /// config.json). Not (de)serialized. (#253)
3331    #[serde(skip)]
3332    pub api_key_from_env: bool,
3333    /// Custom API base URL
3334    #[serde(skip_serializing_if = "Option::is_none")]
3335    pub base_url: Option<String>,
3336    /// Default model to use (e.g., "gemini-2.0-flash-exp")
3337    #[serde(skip_serializing_if = "Option::is_none")]
3338    pub model: Option<String>,
3339    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3340    /// Falls back to `model` when not set.
3341    #[serde(default, skip_serializing_if = "Option::is_none")]
3342    pub fast_model: Option<String>,
3343    /// Vision-capable model for image understanding tasks.
3344    /// Falls back to `model` when not set.
3345    #[serde(default, skip_serializing_if = "Option::is_none")]
3346    pub vision_model: Option<String>,
3347    /// Default reasoning effort for Gemini requests.
3348    #[serde(skip_serializing_if = "Option::is_none")]
3349    pub reasoning_effort: Option<ReasoningEffort>,
3350    /// Optional request overrides (headers/body patches/model rules).
3351    #[serde(default, skip_serializing_if = "Option::is_none")]
3352    pub request_overrides: Option<RequestOverridesConfig>,
3353
3354    /// Preserve unknown keys under `providers.gemini`.
3355    #[serde(default, flatten)]
3356    pub extra: BTreeMap<String, Value>,
3357}
3358
3359/// GitHub Copilot provider configuration
3360///
3361/// # Example
3362///
3363/// ```json
3364/// "copilot": {
3365///   "enabled": true,
3366///   "headless_auth": false,
3367///   "model": "gpt-4o"
3368/// }
3369/// ```
3370#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3371pub struct CopilotConfig {
3372    /// Whether Copilot provider is enabled
3373    #[serde(default)]
3374    pub enabled: bool,
3375    /// Print login URL to console instead of opening browser
3376    #[serde(default)]
3377    pub headless_auth: bool,
3378    /// Default model to use for Copilot (used when clients request the "default" model)
3379    #[serde(skip_serializing_if = "Option::is_none")]
3380    pub model: Option<String>,
3381    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3382    /// Falls back to `model` when not set.
3383    #[serde(default, skip_serializing_if = "Option::is_none")]
3384    pub fast_model: Option<String>,
3385    /// Vision-capable model for image understanding tasks.
3386    /// Falls back to `model` when not set.
3387    #[serde(default, skip_serializing_if = "Option::is_none")]
3388    pub vision_model: Option<String>,
3389    /// Default reasoning effort for Copilot requests.
3390    #[serde(skip_serializing_if = "Option::is_none")]
3391    pub reasoning_effort: Option<ReasoningEffort>,
3392
3393    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3394    ///
3395    /// This is useful for newer Copilot models that only support Responses-style requests.
3396    ///
3397    /// Example:
3398    /// ```json
3399    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3400    /// ```
3401    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3402    pub responses_only_models: Vec<String>,
3403    /// Optional request overrides (headers/body patches/model rules).
3404    #[serde(default, skip_serializing_if = "Option::is_none")]
3405    pub request_overrides: Option<RequestOverridesConfig>,
3406
3407    /// Preserve unknown keys under `providers.copilot`.
3408    #[serde(default, flatten)]
3409    pub extra: BTreeMap<String, Value>,
3410}
3411
3412/// Bodhi proxy provider configuration.
3413///
3414/// Routes LLM requests through a bodhi-server instance so that raw provider
3415/// API keys never reach the client.
3416#[derive(Debug, Clone, Serialize, Deserialize)]
3417pub struct BodhiConfig {
3418    /// Bodhi server API key (e.g. "bhi_sk_xxx").  In-memory only.
3419    #[serde(default, skip_serializing)]
3420    pub api_key: String,
3421    /// Encrypted form of the API key stored on disk.
3422    #[serde(default, skip_serializing_if = "Option::is_none")]
3423    pub api_key_encrypted: Option<String>,
3424    /// Stable reference to the isolated credential store.
3425    #[serde(default, skip_serializing_if = "Option::is_none")]
3426    pub credential_ref: Option<crate::CredentialRef>,
3427    /// Bodhi server base URL.
3428    #[serde(skip_serializing_if = "Option::is_none")]
3429    pub base_url: Option<String>,
3430    /// Which upstream provider to route through bodhi ("openai", "anthropic", "gemini").
3431    #[serde(skip_serializing_if = "Option::is_none")]
3432    pub target_provider: Option<String>,
3433    /// Default reasoning effort.
3434    #[serde(skip_serializing_if = "Option::is_none")]
3435    pub reasoning_effort: Option<ReasoningEffort>,
3436
3437    /// Preserve unknown keys.
3438    #[serde(default, flatten)]
3439    pub extra: BTreeMap<String, Value>,
3440}
3441
3442/// Returns the default provider name ("anthropic")
3443fn default_provider() -> String {
3444    "anthropic".to_string()
3445}
3446
3447// ─── Provider Instance Configuration ──────────────────────────────────
3448
3449/// Configuration for a single provider instance.
3450///
3451/// Multiple instances of the same provider type (e.g. two OpenAI accounts)
3452/// can coexist. Each instance is identified by a stable `instance_id` that
3453/// is used as the routing key in [`ProviderModelRef::provider`] and the
3454/// provider registry.
3455///
3456/// # Example (config.json)
3457///
3458/// ```json
3459/// {
3460///   "provider_instances": {
3461///     "openai-work": {
3462///       "provider_type": "openai",
3463///       "label": "OpenAI (Work)",
3464///       "api_key": "sk-...",
3465///       "model": "gpt-4o"
3466///     },
3467///     "openai-personal": {
3468///       "provider_type": "openai",
3469///       "label": "OpenAI (Personal)",
3470///       "api_key": "sk-...",
3471///       "base_url": "https://api.openai.com/v1",
3472///       "model": "gpt-4o-mini"
3473///     }
3474///   },
3475///   "default_provider_instance": "openai-work"
3476/// }
3477/// ```
3478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3479pub struct ProviderInstanceConfig {
3480    /// Which provider backend this instance targets.
3481    ///
3482    /// Must be one of [`AVAILABLE_PROVIDERS`]: `"openai"`, `"anthropic"`,
3483    /// `"gemini"`, `"copilot"`, `"bodhi"`.
3484    pub provider_type: String,
3485
3486    /// Human-readable label shown in the UI / catalog.
3487    #[serde(default, skip_serializing_if = "Option::is_none")]
3488    pub label: Option<String>,
3489
3490    /// API key (plaintext in memory, encrypted at rest via `api_key_encrypted`).
3491    #[serde(default, skip_serializing)]
3492    pub api_key: String,
3493
3494    /// Encrypted API key (nonce:ciphertext). Written to disk; decrypted into
3495    /// `api_key` on load.
3496    #[serde(default, skip_serializing_if = "Option::is_none")]
3497    pub api_key_encrypted: Option<String>,
3498
3499    /// Stable reference to the isolated credential store.
3500    #[serde(default, skip_serializing_if = "Option::is_none")]
3501    pub credential_ref: Option<crate::CredentialRef>,
3502
3503    /// Custom base URL override.
3504    #[serde(default, skip_serializing_if = "Option::is_none")]
3505    pub base_url: Option<String>,
3506
3507    /// Default chat model for this instance.
3508    #[serde(default, skip_serializing_if = "Option::is_none")]
3509    pub model: Option<String>,
3510
3511    /// Fast/cheap model for lightweight tasks.
3512    #[serde(default, skip_serializing_if = "Option::is_none")]
3513    pub fast_model: Option<String>,
3514
3515    /// Vision-capable model.
3516    #[serde(default, skip_serializing_if = "Option::is_none")]
3517    pub vision_model: Option<String>,
3518
3519    /// Default reasoning effort.
3520    #[serde(default, skip_serializing_if = "Option::is_none")]
3521    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3522
3523    /// Models that must use the Responses API upstream (OpenAI only).
3524    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3525    pub responses_only_models: Vec<String>,
3526
3527    /// Optional request overrides (headers/body patches/model rules).
3528    #[serde(default, skip_serializing_if = "Option::is_none")]
3529    pub request_overrides: Option<RequestOverridesConfig>,
3530
3531    /// Whether this instance is enabled. Disabled instances are skipped
3532    /// during registry construction.
3533    #[serde(default = "default_true")]
3534    pub enabled: bool,
3535
3536    /// Provider-type-specific extra fields preserved through (de)serialization.
3537    #[serde(default, flatten)]
3538    pub extra: BTreeMap<String, Value>,
3539}
3540
3541fn default_true() -> bool {
3542    true
3543}
3544
3545/// Returns the default server port (9562)
3546fn default_port() -> u16 {
3547    9562
3548}
3549
3550/// Returns the default bind address (127.0.0.1)
3551fn default_bind() -> String {
3552    "127.0.0.1".to_string()
3553}
3554
3555/// Returns the default worker count (10)
3556fn default_workers() -> usize {
3557    10
3558}
3559
3560/// Returns the default data directory (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3561fn default_data_dir() -> PathBuf {
3562    super::paths::bamboo_dir()
3563}
3564
3565/// HTTP server configuration
3566#[derive(Debug, Clone, Serialize, Deserialize)]
3567pub struct ServerConfig {
3568    /// Port to listen on
3569    #[serde(default = "default_port")]
3570    pub port: u16,
3571
3572    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
3573    #[serde(default = "default_bind")]
3574    pub bind: String,
3575
3576    /// Static files directory (for Docker mode)
3577    pub static_dir: Option<PathBuf>,
3578
3579    /// Worker count for Actix-web
3580    #[serde(default = "default_workers")]
3581    pub workers: usize,
3582
3583    /// v2 (API v2 transport, #181): optional TLS termination config. When both
3584    /// `cert_file` and `key_file` are given, bamboo terminates TLS itself
3585    /// (rustls, no reverse proxy) and serves `https://` — intended for the
3586    /// public `0.0.0.0` face. When absent, the server keeps the plain `.bind()`
3587    /// / `.listen()` path unchanged (desktop loopback stays plaintext). Missing
3588    /// or unparseable cert/key files are fail-fast at startup, never a silent
3589    /// downgrade to plaintext.
3590    #[serde(default, skip_serializing_if = "Option::is_none")]
3591    pub tls: Option<TlsConfig>,
3592
3593    /// Preserve unknown keys under `server`.
3594    #[serde(default, flatten)]
3595    pub extra: BTreeMap<String, Value>,
3596}
3597
3598impl Default for ServerConfig {
3599    fn default() -> Self {
3600        Self {
3601            port: default_port(),
3602            bind: default_bind(),
3603            static_dir: None,
3604            workers: default_workers(),
3605            tls: None,
3606            extra: BTreeMap::new(),
3607        }
3608    }
3609}
3610
3611/// Manual TLS certificate configuration (current stage; ACME deferred).
3612///
3613/// Both fields point at PEM files: `cert_file` is the full certificate chain
3614/// (leaf → intermediates → root), `key_file` is the matching private key
3615/// (PKCS#8 or RSA). See `docs/api-v2-transport.md` §3.
3616#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3617pub struct TlsConfig {
3618    /// PEM certificate chain (leaf → intermediates → root).
3619    pub cert_file: PathBuf,
3620    /// PEM private key (PKCS#8 or RSA).
3621    pub key_file: PathBuf,
3622}
3623
3624/// Proxy authentication credentials
3625#[derive(Debug, Clone, Serialize, Deserialize)]
3626pub struct ProxyAuth {
3627    /// Proxy username
3628    pub username: String,
3629    /// Proxy password
3630    pub password: String,
3631}
3632
3633/// Parse a boolean value from environment variable strings
3634///
3635/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
3636fn parse_bool_env(value: &str) -> bool {
3637    matches!(
3638        value.trim().to_ascii_lowercase().as_str(),
3639        "1" | "true" | "yes" | "y" | "on"
3640    )
3641}
3642
3643fn expand_user_path(value: &str) -> PathBuf {
3644    let trimmed = value.trim();
3645    if let Some(rest) = trimmed.strip_prefix("~/") {
3646        if let Some(home) = dirs::home_dir() {
3647            return home.join(rest);
3648        }
3649    }
3650    PathBuf::from(trimmed)
3651}
3652
3653impl Default for Config {
3654    fn default() -> Self {
3655        // In-memory defaults ONLY. `default()` must not touch the filesystem or
3656        // environment: it was delegating to `new()` → `from_data_dir(None)`,
3657        // which read config.json from disk, applied BAMBOO_* env overrides, and
3658        // published to the global env-var cache. That made every `..Default::
3659        // default()` struct-update and every test silently disk-dependent and
3660        // let non-server callers clobber the server's in-memory config cache.
3661        // `create_default()` is the pure in-memory constructor; disk loading is
3662        // the explicit job of `new()` / `from_data_dir()`. #38.
3663        Self::create_default()
3664    }
3665}
3666
3667/// Prompt-safe snapshot of configured env vars.
3668#[derive(Debug, Clone, PartialEq, Eq)]
3669pub struct PromptSafeEnvVarEntry {
3670    pub name: String,
3671    pub secret: bool,
3672    pub description: Option<String>,
3673}
3674
3675/// Global cache of user-managed env vars for injection into child processes.
3676///
3677/// Updated whenever the config is loaded or reloaded via [`Config::publish_env_vars`].
3678static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3679
3680static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3681
3682fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3683    ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3684}
3685
3686fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3687    PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3688}
3689
3690impl Config {
3691    fn from_parts(
3692        values: ConfigValues,
3693        memory: Option<MemoryConfig>,
3694        subagents: SubagentsConfig,
3695        providers: ProviderConfigs,
3696    ) -> Self {
3697        Self {
3698            values,
3699            memory: crate::MemoryConfigModule(memory),
3700            subagents: crate::SubagentsConfigModule(subagents),
3701            providers: crate::ProviderConfigsModule(providers),
3702            recovery_status: None,
3703        }
3704    }
3705
3706    /// Crate-internal seam used by the modular section facade.
3707    ///
3708    /// Keeping this conversion here lets the facade exhaustively destructure
3709    /// [`ConfigValues`] in both directions without exposing the compatibility
3710    /// facade's private storage to downstream crates.
3711    pub(crate) fn section_values(&self) -> ConfigValues {
3712        self.values.clone()
3713    }
3714
3715    pub(crate) fn from_section_parts(
3716        values: ConfigValues,
3717        memory: Option<MemoryConfig>,
3718        subagents: SubagentsConfig,
3719        providers: ProviderConfigs,
3720    ) -> Self {
3721        Self::from_parts(values, memory, subagents, providers)
3722    }
3723
3724    /// Compatibility accessor for independently persisted memory settings.
3725    pub fn memory(&self) -> &Option<MemoryConfig> {
3726        &self.memory.0
3727    }
3728
3729    pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3730        &mut self.memory.0
3731    }
3732
3733    /// Compatibility accessor for independently persisted sub-agent settings.
3734    pub fn subagents(&self) -> &SubagentsConfig {
3735        &self.subagents.0
3736    }
3737
3738    pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3739        &mut self.subagents.0
3740    }
3741
3742    /// Compatibility accessor for independently persisted legacy providers.
3743    pub fn providers(&self) -> &ProviderConfigs {
3744        &self.providers.0
3745    }
3746
3747    pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3748        &mut self.providers.0
3749    }
3750
3751    /// Canonicalize only the durable provider view when instance routing is
3752    /// authoritative. Unknown provider entries remain available for forward
3753    /// compatibility.
3754    pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3755        if self
3756            .default_provider_instance
3757            .as_ref()
3758            .is_some_and(|id| self.provider_instances.contains_key(id))
3759        {
3760            self.providers.0.clear_legacy_builtin_aliases();
3761        }
3762    }
3763
3764    /// Build the legacy full-config JSON view used by in-memory patching APIs.
3765    ///
3766    /// Public serde and patch/dot-path callers retain the historical full
3767    /// configuration shape. This value is never the persistence representation:
3768    /// [`Config::save_to_dir`] explicitly writes a root-only DTO plus sidecars.
3769    pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3770        let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3771        let object = value
3772            .as_object_mut()
3773            .expect("ConfigRoot always serializes as a JSON object");
3774        object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3775        object.insert(
3776            "subagents".to_string(),
3777            serde_json::to_value(self.subagents())?,
3778        );
3779        object.insert(
3780            "providers".to_string(),
3781            serde_json::to_value(self.providers())?,
3782        );
3783        Ok(value)
3784    }
3785
3786    /// Load configuration from file with environment variable overrides
3787    ///
3788    /// Configuration loading order:
3789    /// 1. Try loading from `config.json` (`{data_dir}/config.json`)
3790    /// 2. Use defaults
3791    /// 3. Apply environment variable overrides (highest priority)
3792    ///
3793    /// # Environment Variables
3794    ///
3795    /// - `BAMBOO_PORT`: Override server port
3796    /// - `BAMBOO_BIND`: Override bind address
3797    /// - `BAMBOO_DATA_DIR`: Override data directory
3798    /// - `BAMBOO_PROVIDER`: Override default provider
3799    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
3800    /// - `BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION`: Override project durable-memory index prompt injection
3801    /// - `BAMBOO_MEMORY_RELEVANT_RECALL`: Override relevant durable-memory recall prompt injection
3802    /// - `BAMBOO_MEMORY_RELEVANT_RECALL_RERANK`: Override model-based relevant recall reranking
3803    /// - `BAMBOO_MEMORY_PROJECT_FIRST_DREAM`: Override project-first Dream prompt behavior
3804    pub fn new() -> Self {
3805        Self::from_data_dir(None)
3806    }
3807
3808    fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3809        let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3810            Ok(facade) => facade.effective_config(),
3811            Err(error) => {
3812                tracing::warn!(
3813                    error = %error,
3814                    "completed modular configuration is unavailable; refusing legacy-root fallback"
3815                );
3816                Self::create_default()
3817            }
3818        };
3819
3820        if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3821            tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3822            config.proxy_auth = None;
3823        }
3824        if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3825            tracing::warn!(error = %error, "provider credential hydration unavailable");
3826        }
3827        if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3828            tracing::warn!(error = %error, "MCP credential hydration unavailable");
3829        }
3830        if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3831            tracing::warn!(error = %error, "env credential hydration unavailable");
3832            for entry in &mut config.env_vars {
3833                if entry.secret {
3834                    entry.value.clear();
3835                }
3836            }
3837        }
3838        if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3839            tracing::warn!(error = %error, "cluster credential hydration unavailable");
3840            config.clear_cluster_runtime_credentials();
3841        }
3842        if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3843            tracing::warn!(error = %error, "notification credential hydration unavailable");
3844            config.notifications.ntfy.token = None;
3845            config.notifications.bark.device_key = None;
3846        }
3847        if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3848            tracing::warn!(error = %error, "connect credential hydration unavailable");
3849            for platform in &mut config.connect.platforms {
3850                platform.token = None;
3851                platform.app_secret = None;
3852            }
3853        }
3854        if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3855            tracing::warn!(error = %error, "access-control credential hydration unavailable");
3856            config.clear_access_control_runtime_verifiers();
3857        }
3858        if let Some(broker) = config.subagents_mut().broker.as_mut() {
3859            if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3860                tracing::warn!(error = %error, "external broker credential hydration unavailable");
3861                broker.token.clear();
3862            }
3863        }
3864        config.normalize_tool_settings();
3865        config.normalize_skill_settings();
3866        config.normalize_plugin_trust_settings();
3867        config.extra.remove("data_dir");
3868        if apply_env {
3869            config.apply_env_overrides();
3870        }
3871        if publish {
3872            config.publish_env_vars();
3873        }
3874        config
3875    }
3876
3877    /// Load configuration from a specific data directory.
3878    ///
3879    /// Use [`Config::from_data_dir`] (publishes env vars to the global cache, for
3880    /// the context that OWNS the cache — the server bootstrap) or
3881    /// [`Config::from_data_dir_without_publish`] (for non-owning readers that must
3882    /// not clobber the live cache). #40.
3883    ///
3884    /// * `data_dir` - Optional data directory path. If None, uses default (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3885    fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3886        // Determine data_dir early (needed to find config file)
3887        let data_dir = data_dir
3888            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3889            .unwrap_or_else(default_data_dir);
3890
3891        match crate::modular_authority_boundary_present(&data_dir) {
3892            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3893            Ok(false) => {}
3894            Err(error) => {
3895                tracing::warn!(
3896                    error = %error,
3897                    "modular configuration marker is unavailable; refusing legacy-root fallback"
3898                );
3899                let mut config = Self::create_default();
3900                if apply_env {
3901                    config.apply_env_overrides();
3902                }
3903                if publish {
3904                    config.publish_env_vars();
3905                }
3906                return config;
3907            }
3908        }
3909
3910        // Finish any shared manifest-committed credential extraction before
3911        // reading even one member of the transaction, then plan only the
3912        // provider/MCP/root domains. The optional broker has its own planner so
3913        // malformed broker metadata cannot suppress main configuration loading.
3914        let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3915            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3916            .map_err(|error| {
3917                tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3918                error
3919            })
3920            .is_ok();
3921        let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3922            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3923            .map_err(|error| {
3924                tracing::warn!(error = %error, "cluster credential migration unavailable");
3925                error
3926            })
3927            .is_ok();
3928
3929        // A recovery performed by either legacy credential planner may have
3930        // completed the modular split. Reclassify immediately before touching
3931        // config.json so a pending split cannot race this compatibility load
3932        // back into legacy authority.
3933        match crate::modular_authority_boundary_present(&data_dir) {
3934            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3935            Ok(false) => {}
3936            Err(error) => {
3937                tracing::warn!(
3938                    error = %error,
3939                    "modular configuration boundary is unavailable; refusing legacy-root fallback"
3940                );
3941                let mut config = Self::create_default();
3942                if apply_env {
3943                    config.apply_env_overrides();
3944                }
3945                if publish {
3946                    config.publish_env_vars();
3947                }
3948                return config;
3949            }
3950        }
3951
3952        let config_path = data_dir.join("config.json");
3953
3954        let mut config = if config_path.exists() {
3955            if let Ok(content) = std::fs::read_to_string(&config_path) {
3956                Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3957                    // Don't silently discard the user's config on corruption.
3958                    // Quarantine the unparseable file, then recover the MOST recent
3959                    // intent in order: (1) SALVAGE the still-valid fields from the
3960                    // corrupt file (a single bad field shouldn't drop everything),
3961                    // (2) the last-known-good config.json.bak, (3) defaults.
3962                    // #37 / #135. Tag the recovered config with a
3963                    // ConfigRecoveryStatus (unconfirmed) so save_to_dir refuses to
3964                    // overwrite config.json until the caller confirms — the
3965                    // quarantined original stays hand-recoverable until then. #153.
3966                    tracing::warn!(
3967                        "Failed to parse config.json ({}); quarantining it and attempting recovery",
3968                        e
3969                    );
3970                    let quarantine_path = quarantine_corrupt_config(&config_path);
3971                    let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3972                        .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3973                        .or_else(|| {
3974                            Self::load_backup(&data_dir).map(|(cfg, generation)| {
3975                                (cfg, ConfigRecoverySource::Backup { generation })
3976                            })
3977                        })
3978                        .unwrap_or_else(|| {
3979                            tracing::warn!(
3980                                "Could not salvage and no usable config.json.bak; using defaults"
3981                            );
3982                            (Self::create_default(), ConfigRecoverySource::Defaults)
3983                        });
3984                    recovered.recovery_status = Some(ConfigRecoveryStatus {
3985                        source,
3986                        quarantine_path,
3987                        confirmed: false,
3988                    });
3989                    recovered
3990                })
3991            } else {
3992                Self::create_default()
3993            }
3994        } else {
3995            Self::create_default()
3996        };
3997
3998        // Phase-1 registrar migration: an existing sidecar is authoritative;
3999        // when absent, retain the legacy inline value loaded from config.json.
4000        // A malformed sidecar is never rewritten during load and the inline
4001        // value remains available, preventing a bad independent edit from
4002        // erasing the user's last usable configuration.
4003        let mut memory_module = config.memory.clone();
4004        match memory_module.load_sync(&data_dir) {
4005            Ok(true) => config.memory = memory_module,
4006            Ok(false) => {}
4007            Err(error) => tracing::warn!(
4008                "Failed to load memory.json; using legacy config.json memory: {error}"
4009            ),
4010        }
4011        let mut subagents_module = config.subagents.clone();
4012        match subagents_module.load_sync(&data_dir) {
4013            Ok(true) => config.subagents = subagents_module,
4014            Ok(false) => {}
4015            Err(error) => tracing::warn!(
4016                "Failed to load subagents.json; using legacy config.json subagents: {error}"
4017            ),
4018        }
4019        if provider_mcp_ready {
4020            let mut providers_module = config.providers.clone();
4021            match providers_module.load_sync(&data_dir) {
4022                Ok(true) => config.providers = providers_module,
4023                Ok(false) => {}
4024                Err(error) => tracing::warn!(
4025                    "Failed to load providers.json; using legacy config.json providers: {error}"
4026                ),
4027            }
4028        }
4029
4030        // Decrypt encrypted proxy auth into in-memory plaintext form.
4031        config.hydrate_proxy_auth_from_encrypted();
4032        if provider_mcp_ready {
4033            if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
4034                tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
4035                config.proxy_auth = None;
4036            }
4037        }
4038        // Decrypt encrypted provider API keys into in-memory plaintext form.
4039        config.hydrate_provider_api_keys_from_encrypted();
4040        // Decrypt encrypted provider-instance API keys into in-memory plaintext form.
4041        config.hydrate_provider_instance_api_keys_from_encrypted();
4042        // Decrypt encrypted MCP secrets into in-memory plaintext form.
4043        config.hydrate_mcp_secrets_from_encrypted();
4044        if provider_mcp_ready {
4045            if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
4046                tracing::warn!(error = %error, "provider credential hydration unavailable");
4047            }
4048            if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
4049                tracing::warn!(error = %error, "MCP credential hydration unavailable");
4050            }
4051        }
4052        // Decrypt encrypted env vars into in-memory plaintext form.
4053        config.hydrate_env_vars_from_encrypted();
4054        if provider_mcp_ready {
4055            if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
4056                tracing::warn!(error = %error, "env credential hydration unavailable");
4057                for entry in &mut config.env_vars {
4058                    if entry.secret {
4059                        entry.value.clear();
4060                    }
4061                }
4062            }
4063        }
4064        // Cluster migration is deliberately independent from provider/MCP
4065        // readiness. A malformed optional fabric fails only cluster runtime
4066        // authentication, while missing/corrupt refs never fall back to legacy
4067        // ciphertext or an unauthenticated SSH attempt.
4068        if cluster_ready {
4069            if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
4070                tracing::warn!(error = %error, "cluster credential hydration unavailable");
4071                config.clear_cluster_runtime_credentials();
4072            }
4073        } else {
4074            config.clear_cluster_runtime_credentials();
4075        }
4076        // Decrypt the encrypted broker token into in-memory plaintext.
4077        config.hydrate_broker_token_from_encrypted();
4078        // Decrypt encrypted notification-channel secrets into in-memory plaintext.
4079        config.hydrate_notifications_from_encrypted();
4080        if provider_mcp_ready {
4081            if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
4082                tracing::warn!(error = %error, "notification credential hydration unavailable");
4083                config.notifications.ntfy.token = None;
4084                config.notifications.bark.device_key = None;
4085            }
4086        } else {
4087            // A pending or unreadable credential migration means the isolated
4088            // store is not authoritative yet. Legacy plaintext/ciphertext may
4089            // remain on disk for recovery, but notification sinks must not use
4090            // it in this process.
4091            config.notifications.ntfy.token = None;
4092            config.notifications.bark.device_key = None;
4093        }
4094        // Merge the standalone connect.json (#455) onto `config.connect`,
4095        // migrating a legacy inline `connect` key from config.json (#453
4096        // state) when present. MUST run before the token hydration below so
4097        // it decrypts the post-merge ciphertext, not a stale/legacy copy.
4098        config.merge_connect_config(&data_dir);
4099        // One-time (idempotent) sweep of the rotated config.json.bak[.N]
4100        // generations for a legacy embedded `connect` sub-tree left behind by
4101        // a pre-#455 build (#468, follow-up to #457). Independent of whether
4102        // `merge_connect_config` just migrated the CURRENT config.json above —
4103        // an instance that was already migrated by an earlier run of this
4104        // binary has a clean config.json today but may still carry the
4105        // legacy key in an untouched `.bak`/`.bak.1`/`.bak.2` generation, since
4106        // backup rotation only overwrites those on a fresh SAVE. Runs on every
4107        // load but is cheap and a no-op once every generation has been swept.
4108        scrub_legacy_connect_from_config_backups(&data_dir);
4109        // Decrypt encrypted bamboo-connect platform tokens into in-memory plaintext.
4110        config.hydrate_connect_platform_tokens_from_encrypted();
4111        if provider_mcp_ready {
4112            if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
4113                tracing::warn!(error = %error, "connect credential hydration unavailable");
4114                for platform in &mut config.connect.platforms {
4115                    platform.token = None;
4116                    platform.app_secret = None;
4117                }
4118            }
4119        }
4120        if provider_mcp_ready {
4121            if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
4122                tracing::warn!(error = %error, "access-control credential hydration unavailable");
4123                config.clear_access_control_runtime_verifiers();
4124            }
4125        } else {
4126            config.clear_access_control_runtime_verifiers();
4127        }
4128        config.normalize_tool_settings();
4129        config.normalize_skill_settings();
4130        config.normalize_plugin_trust_settings();
4131
4132        // Legacy: `data_dir` is no longer a persisted config field. The data directory is
4133        // derived from runtime (BAMBOO_DATA_DIR or `${HOME}/.bamboo`).
4134        config.extra.remove("data_dir");
4135
4136        // Apply environment variable overrides (highest priority). Skipped by
4137        // one-shot CLI writers (`bamboo init` / `config set`) so transient
4138        // `BAMBOO_*` values are never baked into the persisted config.json.
4139        if apply_env {
4140            config.apply_env_overrides();
4141        }
4142
4143        // Publish env vars to the global cache so Bash tools can inject them —
4144        // ONLY when the caller owns that cache. Non-owning readers pass
4145        // publish=false so they don't clobber the server's live env-var cache.
4146        if publish {
4147            config.publish_env_vars();
4148        }
4149
4150        config
4151    }
4152
4153    /// Apply `BAMBOO_*` environment overrides (highest priority) onto a loaded
4154    /// config. Factored out so one-shot writers can skip it (see
4155    /// [`Config::from_data_dir_without_env`]).
4156    fn apply_env_overrides(&mut self) {
4157        if let Ok(port) = std::env::var("BAMBOO_PORT") {
4158            if let Ok(port) = port.parse() {
4159                self.server.port = port;
4160            }
4161        }
4162
4163        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
4164            self.server.bind = bind;
4165        }
4166
4167        // Note: BAMBOO_DATA_DIR already handled by the caller. In instance
4168        // mode the override may name either an exact instance id or a provider
4169        // type. Type matches are ordered by instance id so the result is
4170        // deterministic even for a multi-account configuration.
4171        if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
4172            let provider = provider.trim().to_string();
4173            if !provider.is_empty() {
4174                self.provider = provider.clone();
4175                if !self.provider_instances.is_empty() {
4176                    let selected = self
4177                        .provider_instances
4178                        .contains_key(&provider)
4179                        .then_some(provider.clone())
4180                        .or_else(|| {
4181                            let mut matching = self
4182                                .provider_instances
4183                                .iter()
4184                                .filter(|(_, instance)| {
4185                                    instance.enabled && instance.provider_type == provider
4186                                })
4187                                .map(|(id, _)| id.clone())
4188                                .collect::<Vec<_>>();
4189                            matching.sort();
4190                            matching.into_iter().next()
4191                        });
4192                    self.default_provider_instance = selected;
4193                }
4194            }
4195        }
4196
4197        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
4198            self.headless_auth = parse_bool_env(&headless);
4199        }
4200
4201        if let Ok(project_prompt_injection) =
4202            std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
4203        {
4204            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4205            memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
4206        }
4207
4208        if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
4209            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4210            memory.relevant_recall = parse_bool_env(&relevant_recall);
4211        }
4212
4213        if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4214            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4215            memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4216        }
4217
4218        if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4219            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4220            memory.project_first_dream = parse_bool_env(&project_first_dream);
4221        }
4222
4223        // Per-provider API keys from the environment (highest priority). Lets a
4224        // 12-factor / secret-manager / --env-file / k8s-Secret deploy supply the
4225        // key at runtime instead of baking a plaintext `api_key` into a mounted
4226        // config.json. The `api_key_from_env` flag keeps `refresh_provider_api_keys_encrypted`
4227        // from re-encrypting these keys into `api_key_encrypted` on a later save,
4228        // so an env key is never persisted to disk. (#253)
4229        if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4230            let key = key.trim();
4231            if !key.is_empty() {
4232                if self.provider_instances.is_empty() {
4233                    let openai = self
4234                        .providers
4235                        .openai
4236                        .get_or_insert_with(OpenAIConfig::default);
4237                    openai.api_key = key.to_string();
4238                    openai.api_key_from_env = true;
4239                } else {
4240                    self.apply_provider_instance_env_key("openai", key);
4241                }
4242            }
4243        }
4244        if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4245            let key = key.trim();
4246            if !key.is_empty() {
4247                if self.provider_instances.is_empty() {
4248                    let anthropic = self
4249                        .providers
4250                        .anthropic
4251                        .get_or_insert_with(AnthropicConfig::default);
4252                    anthropic.api_key = key.to_string();
4253                    anthropic.api_key_from_env = true;
4254                } else {
4255                    self.apply_provider_instance_env_key("anthropic", key);
4256                }
4257            }
4258        }
4259        if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4260            let key = key.trim();
4261            if !key.is_empty() {
4262                if self.provider_instances.is_empty() {
4263                    let gemini = self
4264                        .providers
4265                        .gemini
4266                        .get_or_insert_with(GeminiConfig::default);
4267                    gemini.api_key = key.to_string();
4268                    gemini.api_key_from_env = true;
4269                } else {
4270                    self.apply_provider_instance_env_key("gemini", key);
4271                }
4272            }
4273        }
4274    }
4275
4276    fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4277        for instance in self.provider_instances.values_mut().filter(|instance| {
4278            instance.provider_type == provider_type
4279                && crate::provider_instance_api_key_from_env(instance)
4280        }) {
4281            instance.api_key = key.to_string();
4282            instance.api_key_encrypted = None;
4283        }
4284    }
4285
4286    /// Apply runtime-only `BAMBOO_*` overrides to a config assembled by the
4287    /// modular facade. Facade callers use this after durable section snapshots
4288    /// and credential references have been materialized; one-shot writers keep
4289    /// using the no-env load path so overrides are never persisted.
4290    pub fn apply_runtime_env_overrides(&mut self) {
4291        self.apply_env_overrides();
4292    }
4293
4294    /// Load config from disk AND publish its env vars to the process-global cache
4295    /// (so Bash tools inject them). For the context that OWNS that cache — the
4296    /// server bootstrap. Library / secondary readers that only need to read a
4297    /// value must use [`Config::from_data_dir_without_publish`] instead, or they
4298    /// will clobber the server's live cache with stale disk data (#38 / #40).
4299    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4300        Self::from_data_dir_impl(data_dir, true, true)
4301    }
4302
4303    /// Load config from disk WITHOUT publishing env vars to the global cache.
4304    /// For non-owning readers (e.g. permission storage) that just need a config
4305    /// value and must not clobber the live env-var cache. #40.
4306    pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4307        Self::from_data_dir_impl(data_dir, false, true)
4308    }
4309
4310    /// Load config from disk WITHOUT applying `BAMBOO_*` env-var overrides and
4311    /// WITHOUT publishing to the global cache. For one-shot CLI writers
4312    /// (`bamboo init` / `config set`) that immediately re-save: applying env
4313    /// overrides here would bake transient values (port/bind/provider/memory
4314    /// flags) permanently into config.json. Same corruption-recovery + default
4315    /// fallback as the normal load.
4316    pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4317        Self::from_data_dir_impl(data_dir, false, false)
4318    }
4319
4320    /// Merge the standalone `connect.json` (#455) onto `self.connect`, the
4321    /// load-side counterpart of [`save_connect_config`]. Called once per load,
4322    /// BEFORE [`Config::hydrate_connect_platform_tokens_from_encrypted`] runs,
4323    /// so hydration decrypts the POST-merge ciphertext rather than a stale
4324    /// copy still embedded in `config.json`.
4325    ///
4326    /// - `connect.json` present & parseable: authoritative — OVERWRITES
4327    ///   whatever `self.connect` currently holds. If `self.connect` was ALSO
4328    ///   non-empty (a legacy inline `connect` key still in config.json, e.g.
4329    ///   #453-era state, or written by an older binary), that's a stale
4330    ///   duplicate: log a warning and proactively strip the superseded key
4331    ///   from config.json now (#457) rather than waiting for the next
4332    ///   natural save — cheap, and consistent with not spreading token
4333    ///   ciphertext across files.
4334    /// - `connect.json` present but corrupt/unparsable: fail SAFE for this
4335    ///   security-sensitive feature. Log an error, quarantine the bad file to
4336    ///   `connect.json.bak` (best-effort), and continue with an EMPTY
4337    ///   `ConnectConfig` — never falls back to a legacy config.json copy.
4338    /// - `connect.json` absent & `self.connect` non-empty (pure legacy
4339    ///   state): migrate proactively. Adopt the legacy value (already parsed
4340    ///   into `self`) and persist it: strip the `connect` key from
4341    ///   config.json and write connect.json (#457 — NOT a full
4342    ///   [`Config::save_to_dir`], which would re-encrypt every OTHER secret
4343    ///   in config.json and rotate its backups as a load-time side effect,
4344    ///   even for a read-only command like `bamboo config get`), logged at
4345    ///   info.
4346    /// - `connect.json` absent & `self.connect` empty: nothing to do.
4347    fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4348        let connect_path = data_dir.join("connect.json");
4349        match std::fs::read_to_string(&connect_path) {
4350            Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4351                Ok(connect) => {
4352                    let legacy_key_present = !connect_config_is_empty(&self.connect);
4353                    if legacy_key_present {
4354                        tracing::warn!(
4355                            "config.json still has a legacy `connect` key alongside \
4356                             connect.json; connect.json takes precedence — dropping the \
4357                             stale key from config.json now"
4358                        );
4359                    }
4360                    self.connect = connect;
4361                    if legacy_key_present {
4362                        strip_legacy_connect_key_from_config_json(data_dir);
4363                    }
4364                }
4365                Err(e) => {
4366                    tracing::error!(
4367                        "Failed to parse {:?} ({}); continuing with an empty (inert) \
4368                         connect config instead of falling back to a legacy config.json copy",
4369                        connect_path,
4370                        e
4371                    );
4372                    quarantine_corrupt_connect(&connect_path);
4373                    self.connect = ConnectConfig::default();
4374                }
4375            },
4376            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4377                if !connect_config_is_empty(&self.connect) {
4378                    tracing::info!(
4379                        "Migrating legacy `connect` config from config.json to a \
4380                         standalone connect.json"
4381                    );
4382                    // Narrow migration write (#457): strip only the `connect` key
4383                    // from config.json and write connect.json directly, instead of
4384                    // routing through a full `save_to_dir` (see doc comment above).
4385                    strip_legacy_connect_key_from_config_json(data_dir);
4386                    if let Err(e) = save_connect_config(&self.connect, data_dir) {
4387                        tracing::error!("Failed to write connect.json during migration: {}", e);
4388                    }
4389                }
4390            }
4391            Err(e) => {
4392                tracing::error!(
4393                    "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4394                    connect_path,
4395                    e
4396                );
4397                self.connect = ConnectConfig::default();
4398            }
4399        }
4400    }
4401
4402    /// Deserialize config JSON and run the in-memory hydration + normalization
4403    /// chain. Shared by the primary load and the backup-recovery path (#37).
4404    fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4405        serde_json::from_str::<Config>(content).map(|mut config| {
4406            config.hydrate_proxy_auth_from_encrypted();
4407            config.hydrate_provider_api_keys_from_encrypted();
4408            config.hydrate_provider_instance_api_keys_from_encrypted();
4409            config.hydrate_mcp_secrets_from_encrypted();
4410            config.hydrate_env_vars_from_encrypted();
4411            config.hydrate_cluster_fabric_from_encrypted();
4412            config.hydrate_broker_token_from_encrypted();
4413            config.hydrate_notifications_from_encrypted();
4414            config.hydrate_connect_platform_tokens_from_encrypted();
4415            config.normalize_tool_settings();
4416            config.normalize_skill_settings();
4417            config
4418        })
4419    }
4420
4421    /// Try to recover from the rotated `config.json.bak[.N]` generations (each a
4422    /// last-known-good written before a save) when the primary `config.json` is
4423    /// corrupt. Walks newest -> oldest and returns the first that parses (paired
4424    /// with its generation index, 0 == `.bak`, for [`ConfigRecoverySource::Backup`]);
4425    /// `None` if every generation is missing or also unparseable. #37 / #135.
4426    fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4427        let config_path = data_dir.join("config.json");
4428        for gen in 0..BAK_GENERATIONS {
4429            let backup = backup_path_for(&config_path, gen);
4430            let Ok(content) = std::fs::read_to_string(&backup) else {
4431                continue;
4432            };
4433            match Self::parse_and_hydrate(&content) {
4434                Ok(config) => {
4435                    tracing::info!("Recovered configuration from {:?}", backup);
4436                    return Some((config, gen));
4437                }
4438                Err(e) => {
4439                    tracing::warn!(
4440                        "Backup {:?} is unparseable ({}); trying an older generation",
4441                        backup,
4442                        e
4443                    );
4444                }
4445            }
4446        }
4447        None
4448    }
4449
4450    /// Largest corrupt-object key count we'll attempt to salvage. The overlay loop
4451    /// is O(keys) full-Config deserializes over a growing object (the `extra`
4452    /// catch-all absorbs unknown keys), i.e. O(n²) on a pathological file; cap it
4453    /// so a junk-key-flooded config.json can't stall a load. A real config has a
4454    /// few dozen top-level keys, so this only ever trips on garbage.
4455    const SALVAGE_MAX_KEYS: usize = 512;
4456
4457    /// Best-effort PARTIAL salvage of a corrupt `config.json` (#135): parse it as a
4458    /// generic JSON object and overlay each top-level field onto the richest
4459    /// known-good baseline — the last-known-good `config.json.bak` if present, else
4460    /// a fresh default — keeping only the fields that still yield a valid [`Config`].
4461    /// A single bad field (wrong type, malformed section, …) then keeps the
4462    /// baseline's value instead of discarding ALL the user's other settings.
4463    ///
4464    /// Overlaying onto `.bak` (rather than defaults) means the result is the
4465    /// best-of-both: the backup's complete recent-good state PLUS the corrupt
4466    /// file's still-valid newer edits on top — so salvage is never worse than the
4467    /// plain `.bak` fallback, removing the "sparse salvage defeats a rich backup"
4468    /// hazard. Tried BEFORE the bare `.bak` fallback.
4469    ///
4470    /// Returns the hydrated salvaged config, or `None` when the corrupt file isn't
4471    /// even a JSON object (nothing field-wise to salvage) so the caller falls
4472    /// through to `.bak` / defaults.
4473    ///
4474    /// NOTE: the per-field overlay guarantees a VALID `Config`, not a *maximal* or
4475    /// attribution-perfect one. Deterministic alphabetical key order (serde_json is
4476    /// BTreeMap-backed, no `preserve_order`) means a rename/alias pair like
4477    /// `mcp`/`mcpServers` can drop the second-seen even if it'd be valid alone — the
4478    /// outcome is still a valid config, just not necessarily the richest possible.
4479    ///
4480    /// Returns the hydrated salvaged config paired with the top-level keys that
4481    /// were actually recovered from the corrupt document (used to populate
4482    /// [`ConfigRecoverySource::Salvaged`]).
4483    fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4484        // Must at least be a JSON object; otherwise there's nothing field-wise to
4485        // salvage (a truncated/garbage file just falls through to .bak/defaults).
4486        let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4487        let corrupt_obj = corrupt.as_object()?;
4488        if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4489            tracing::warn!(
4490                "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4491                corrupt_obj.len(),
4492                Self::SALVAGE_MAX_KEYS
4493            );
4494            return None;
4495        }
4496
4497        // Overlay onto the richest known-good baseline: the last-known-good backup
4498        // if it parses, else a fresh default. This makes salvage >= the plain .bak
4499        // fallback in every case.
4500        let mut base = Self::load_backup(data_dir)
4501            .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4502            .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4503        let base_obj = base.as_object_mut()?;
4504
4505        let mut salvaged: Vec<String> = Vec::new();
4506        for (key, value) in corrupt_obj {
4507            let previous = base_obj.insert(key.clone(), value.clone());
4508            // Keep the field iff the WHOLE config still deserializes with it
4509            // overlaid — base is valid before each step, so a failure isolates THIS
4510            // field as the corrupt one (and inter-field constraints are respected).
4511            if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4512                salvaged.push(key.clone());
4513            } else {
4514                match previous {
4515                    Some(prev) => {
4516                        base_obj.insert(key.clone(), prev);
4517                    }
4518                    None => {
4519                        base_obj.remove(key);
4520                    }
4521                }
4522            }
4523        }
4524
4525        tracing::warn!(
4526            "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4527             last-known-good/default value",
4528            salvaged.len(),
4529            salvaged.join(", ")
4530        );
4531
4532        // Re-serialize the rebuilt (all-valid) object and run it back through the
4533        // normal parse+hydrate path so secret-decryption / normalization match a
4534        // clean load exactly.
4535        let rebuilt = serde_json::to_string(&base).ok()?;
4536        Self::parse_and_hydrate(&rebuilt)
4537            .ok()
4538            .map(|config| (config, salvaged))
4539    }
4540
4541    /// Get the effective default model for the currently active provider.
4542    ///
4543    /// When `features.provider_model_ref` is enabled, reads from `defaults.chat`
4544    /// before falling back to legacy provider-specific config.
4545    ///
4546    /// Note: for most providers this is a required config value (returns None when absent).
4547    /// Copilot has a built-in fallback when no model is configured.
4548    pub fn get_model(&self) -> Option<String> {
4549        if self.features.provider_model_ref {
4550            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4551                return Some(model_ref.model.clone());
4552            }
4553        }
4554        let provider = self.effective_default_provider();
4555        if let Some(instance) = self.provider_instances.get(provider) {
4556            return instance
4557                .model
4558                .clone()
4559                .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4560        }
4561        match provider {
4562            "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4563            "anthropic" => self
4564                .providers
4565                .anthropic
4566                .as_ref()
4567                .and_then(|c| c.model.clone()),
4568            "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4569            "copilot" => Some(
4570                self.providers
4571                    .copilot
4572                    .as_ref()
4573                    .and_then(|c| c.model.clone())
4574                    .unwrap_or_else(|| "gpt-4o".to_string()),
4575            ),
4576            _ => None,
4577        }
4578    }
4579
4580    /// Get the fast/cheap model for the currently active provider.
4581    ///
4582    /// When `features.provider_model_ref` is enabled, reads from `defaults.fast`
4583    /// before falling back to legacy provider-specific config.
4584    ///
4585    /// Used for lightweight tasks like title generation and summarization.
4586    /// Falls back to `get_model()` when no fast_model is configured.
4587    pub fn get_fast_model(&self) -> Option<String> {
4588        if self.features.provider_model_ref {
4589            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4590                return Some(model_ref.model.clone());
4591            }
4592        }
4593        let provider = self.effective_default_provider();
4594        let fast = if let Some(instance) = self.provider_instances.get(provider) {
4595            instance.fast_model.clone()
4596        } else {
4597            match provider {
4598                "openai" => self
4599                    .providers
4600                    .openai
4601                    .as_ref()
4602                    .and_then(|c| c.fast_model.clone()),
4603                "anthropic" => self
4604                    .providers
4605                    .anthropic
4606                    .as_ref()
4607                    .and_then(|c| c.fast_model.clone()),
4608                "gemini" => self
4609                    .providers
4610                    .gemini
4611                    .as_ref()
4612                    .and_then(|c| c.fast_model.clone()),
4613                "copilot" => self
4614                    .providers
4615                    .copilot
4616                    .as_ref()
4617                    .and_then(|c| c.fast_model.clone()),
4618                _ => None,
4619            }
4620        };
4621        fast.or_else(|| self.get_model())
4622    }
4623
4624    /// Get the configured task summarization model.
4625    ///
4626    /// When `features.provider_model_ref` is enabled, reads from
4627    /// `defaults.task_summary` before falling back through
4628    /// `defaults.fast` → `defaults.chat`.
4629    ///
4630    /// This is used for conversation/task summarization and context compression.
4631    pub fn get_task_summary_model(&self) -> Option<String> {
4632        if self.features.provider_model_ref {
4633            if let Some(model_ref) = self
4634                .defaults
4635                .as_ref()
4636                .and_then(|d| d.task_summary.as_ref())
4637                .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4638                .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4639            {
4640                return Some(model_ref.model.clone());
4641            }
4642        }
4643
4644        self.get_fast_model()
4645    }
4646
4647    /// Get the configured memory/background summarization model.
4648    ///
4649    /// When `features.provider_model_ref` is enabled, reads from
4650    /// `defaults.memory_background` before falling back through
4651    /// `defaults.fast` → `defaults.chat`.
4652    ///
4653    /// Legacy configuration follows the same dedicated → fast → chat order.
4654    pub fn get_memory_background_model(&self) -> Option<String> {
4655        if self.features.provider_model_ref {
4656            if let Some(model_ref) = self
4657                .defaults
4658                .as_ref()
4659                .and_then(|d| d.memory_background.as_ref())
4660            {
4661                return Some(model_ref.model.clone());
4662            }
4663            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4664                return Some(model_ref.model.clone());
4665            }
4666            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4667                return Some(model_ref.model.clone());
4668            }
4669        }
4670        let configured = self
4671            .memory
4672            .as_ref()
4673            .and_then(|memory| memory.background_model.as_ref())
4674            .map(|value| value.trim())
4675            .filter(|value| !value.is_empty())
4676            .map(ToString::to_string);
4677        configured.or_else(|| self.get_fast_model())
4678    }
4679
4680    /// Resolve the configured default work area path when present.
4681    ///
4682    /// This validates that the configured directory exists, but intentionally
4683    /// returns the stable expanded path rather than the platform-specific
4684    /// canonicalized path. On macOS, `canonicalize()` may rewrite `/var/...`
4685    /// to `/private/var/...`, which is correct at the filesystem layer but
4686    /// undesirable as a user-facing/config-derived workspace path.
4687    pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4688        let raw = self
4689            .default_work_area
4690            .as_ref()
4691            .and_then(|config| config.path.as_ref())
4692            .map(|value| value.trim())
4693            .filter(|value| !value.is_empty())?;
4694
4695        let candidate = expand_user_path(raw);
4696        if candidate.is_absolute() {
4697            let canonical = std::fs::canonicalize(&candidate).ok();
4698            return canonical
4699                .as_ref()
4700                .filter(|path| path.is_dir())
4701                .map(|_| candidate.clone())
4702                .or_else(|| candidate.is_dir().then_some(candidate));
4703        }
4704
4705        let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4706        let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4707        canonical
4708            .as_ref()
4709            .filter(|path| path.is_dir())
4710            .map(|_| from_bamboo_dir.clone())
4711            .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4712            .or_else(|| candidate.is_dir().then_some(candidate))
4713    }
4714
4715    /// Get the vision-capable model for the currently active provider.
4716    ///
4717    /// Used for image understanding tasks.
4718    /// Falls back to the fast model, then the chat model, when no vision model
4719    /// is configured.
4720    pub fn get_vision_model(&self) -> Option<String> {
4721        if self.features.provider_model_ref {
4722            if let Some(model_ref) = self
4723                .defaults
4724                .as_ref()
4725                .and_then(|defaults| defaults.vision.as_ref())
4726                .or_else(|| {
4727                    self.defaults
4728                        .as_ref()
4729                        .and_then(|defaults| defaults.fast.as_ref())
4730                })
4731                .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4732            {
4733                return Some(model_ref.model.clone());
4734            }
4735        }
4736        let provider = self.effective_default_provider();
4737        let vision = if let Some(instance) = self.provider_instances.get(provider) {
4738            instance.vision_model.clone()
4739        } else {
4740            match provider {
4741                "openai" => self
4742                    .providers
4743                    .openai
4744                    .as_ref()
4745                    .and_then(|c| c.vision_model.clone()),
4746                "anthropic" => self
4747                    .providers
4748                    .anthropic
4749                    .as_ref()
4750                    .and_then(|c| c.vision_model.clone()),
4751                "gemini" => self
4752                    .providers
4753                    .gemini
4754                    .as_ref()
4755                    .and_then(|c| c.vision_model.clone()),
4756                "copilot" => self
4757                    .providers
4758                    .copilot
4759                    .as_ref()
4760                    .and_then(|c| c.vision_model.clone()),
4761                _ => None,
4762            }
4763        };
4764        vision.or_else(|| self.get_fast_model())
4765    }
4766
4767    /// Get the default reasoning effort for the chat role.
4768    ///
4769    /// A role-specific preference wins over the provider-instance default.
4770    pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4771        if self.features.provider_model_ref {
4772            if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4773                return chat
4774                    .reasoning_effort
4775                    .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4776            }
4777        }
4778        self.reasoning_effort_for_key(self.effective_default_provider())
4779    }
4780
4781    /// Resolve the configured default reasoning effort for a provider routing key.
4782    ///
4783    /// The key may be a multi-instance provider id (for example `"copilot-work"`)
4784    /// or a legacy provider type (for example `"openai"`). In multi-instance mode
4785    /// the per-instance `reasoning_effort` lives under `provider_instances[<id>]`,
4786    /// so we resolve instance ids there first; otherwise we fall back to the
4787    /// legacy per-provider config. Both the execute path
4788    /// ([`crate`]'s `get_reasoning_effort_for_provider`) and the session-create
4789    /// path ([`Self::get_reasoning_effort`]) delegate here so the two cannot drift.
4790    pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4791        let trimmed = key.trim();
4792        if trimmed.is_empty() {
4793            return None;
4794        }
4795
4796        // Multi-instance mode: the routing key is an instance id.
4797        if let Some(instance) = self.provider_instances.get(trimmed) {
4798            return instance.reasoning_effort;
4799        }
4800
4801        // Legacy mode: the routing key is a provider type.
4802        match trimmed {
4803            "openai" => self
4804                .providers
4805                .openai
4806                .as_ref()
4807                .and_then(|c| c.reasoning_effort),
4808            "anthropic" => self
4809                .providers
4810                .anthropic
4811                .as_ref()
4812                .and_then(|c| c.reasoning_effort),
4813            "gemini" => self
4814                .providers
4815                .gemini
4816                .as_ref()
4817                .and_then(|c| c.reasoning_effort),
4818            "copilot" => self
4819                .providers
4820                .copilot
4821                .as_ref()
4822                .and_then(|c| c.reasoning_effort),
4823            "bodhi" => self
4824                .providers
4825                .bodhi
4826                .as_ref()
4827                .and_then(|c| c.reasoning_effort),
4828            _ => None,
4829        }
4830    }
4831
4832    /// Get exact disabled tool references for catalog-aware resolution.
4833    ///
4834    /// References remain exact here: catalog-aware filtering resolves an exact
4835    /// registered name before applying legacy/builtin alias fallback. Eagerly
4836    /// rewriting `apply_patch` to `Edit`, for example, would make an exact
4837    /// custom `apply_patch` registration indistinguishable from the builtin.
4838    pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4839        self.tools
4840            .disabled
4841            .iter()
4842            .map(|name| name.trim())
4843            .filter(|name| !name.is_empty())
4844            .map(str::to_string)
4845            .collect()
4846    }
4847
4848    /// Legacy normalized-name facade retained for source compatibility.
4849    ///
4850    /// New execution/catalog code must use [`Self::disabled_tool_references`]
4851    /// so an exact registered alias can be resolved before fallback.
4852    pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4853        self.disabled_tool_references()
4854            .into_iter()
4855            .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4856            .collect()
4857    }
4858
4859    /// Normalize tool settings (trim / dedupe / sort).
4860    pub fn normalize_tool_settings(&mut self) {
4861        self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4862    }
4863
4864    /// Get normalized disabled skill IDs.
4865    pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4866        self.skills
4867            .disabled
4868            .iter()
4869            .map(|id| id.trim())
4870            .filter(|id| !id.is_empty())
4871            .map(|id| id.to_string())
4872            .collect()
4873    }
4874
4875    /// Normalize skill settings (trim / dedupe / sort).
4876    pub fn normalize_skill_settings(&mut self) {
4877        self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4878    }
4879
4880    /// Normalize `plugin_trust.trusted_hosts` entries (trim / lowercase / drop
4881    /// empties) so a hand-edited `config.json` doesn't silently accumulate
4882    /// mixed-case or whitespace-padded entries. [`is_host_trusted`] itself
4883    /// already matches case-insensitively regardless of how an entry is
4884    /// stored, so this is defense in depth / a canonical on-disk form, not
4885    /// the source of the security fix — that's the host/path-component
4886    /// matching in [`is_host_trusted`] itself.
4887    pub fn normalize_plugin_trust_settings(&mut self) {
4888        self.plugin_trust.trusted_hosts = self
4889            .plugin_trust
4890            .trusted_hosts
4891            .iter()
4892            .map(|entry| entry.trim().to_ascii_lowercase())
4893            .filter(|entry| !entry.is_empty())
4894            .collect();
4895    }
4896
4897    /// Return the effective default provider key.
4898    ///
4899    /// Prefers `default_provider_instance` when set; falls back to the
4900    /// legacy `provider` string.
4901    pub fn effective_default_provider(&self) -> &str {
4902        self.default_provider_instance
4903            .as_deref()
4904            .unwrap_or(&self.provider)
4905    }
4906
4907    /// Whether provider instances are configured (new multi-instance path).
4908    pub fn has_provider_instances(&self) -> bool {
4909        !self.provider_instances.is_empty()
4910    }
4911
4912    /// Build a flat map of all env vars with non-empty values (for process injection).
4913    pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4914        self.env_vars
4915            .iter()
4916            .filter(|e| !e.value.trim().is_empty())
4917            .map(|e| (e.name.clone(), e.value.clone()))
4918            .collect()
4919    }
4920
4921    fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4922        self.env_vars
4923            .iter()
4924            .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4925            .map(|entry| PromptSafeEnvVarEntry {
4926                name: entry.name.clone(),
4927                secret: entry.secret,
4928                description: entry
4929                    .description
4930                    .as_ref()
4931                    .map(|value| value.trim().to_string())
4932                    .filter(|value| !value.is_empty()),
4933            })
4934            .collect()
4935    }
4936
4937    /// Update the global env vars cache (called on config load / reload).
4938    pub fn publish_env_vars(&self) {
4939        let map = self.env_vars_as_map();
4940
4941        #[cfg(any(test, feature = "test-utils"))]
4942        if crate::test_support::env_vars_cache_override_is_active() {
4943            crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4944            return;
4945        }
4946
4947        let mut env_guard = env_vars_cache().write().recover_poison();
4948        *env_guard = map;
4949
4950        let prompt_safe = self.prompt_safe_env_vars();
4951        let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4952        *prompt_guard = prompt_safe;
4953    }
4954
4955    /// Read the current env vars snapshot (called by Bash tool at process spawn time).
4956    pub fn current_env_vars() -> HashMap<String, String> {
4957        #[cfg(any(test, feature = "test-utils"))]
4958        if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4959            return env_vars;
4960        }
4961
4962        env_vars_cache().read().recover_poison().clone()
4963    }
4964
4965    /// Read the current prompt-safe env var snapshot (names + metadata only; no secret values).
4966    pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4967        #[cfg(any(test, feature = "test-utils"))]
4968        if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4969            return env_vars;
4970        }
4971
4972        prompt_safe_env_vars_cache().read().recover_poison().clone()
4973    }
4974
4975    /// Create a default configuration without loading from file
4976    fn create_default() -> Self {
4977        Self::from_parts(
4978            ConfigValues {
4979                http_proxy: String::new(),
4980                https_proxy: String::new(),
4981                proxy_auth: None,
4982                proxy_auth_encrypted: None,
4983                proxy_auth_credential_ref: None,
4984                headless_auth: false,
4985                run_budget: RunBudgetConfig::default(),
4986                stream_timeout: StreamTimeoutConfig::default(),
4987                llm_retry: None,
4988                context_management: ContextManagementConfig::default(),
4989                cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4990                provider: default_provider(),
4991                provider_instances: HashMap::new(),
4992                default_provider_instance: None,
4993                server: ServerConfig::default(),
4994                keyword_masking: KeywordMaskingConfig::default(),
4995                anthropic_model_mapping: AnthropicModelMapping::default(),
4996                gemini_model_mapping: GeminiModelMapping::default(),
4997                patrol_model: None,
4998                hooks: HooksConfig::default(),
4999                lifecycle_hooks: LifecycleHooksConfig::default(),
5000                tools: ToolsConfig::default(),
5001                skills: SkillsConfig::default(),
5002                env_vars: Vec::new(),
5003                default_work_area: None,
5004                worktrees: WorktreeSettings::default(),
5005                access_control: None,
5006                features: FeatureFlags::default(),
5007                defaults: None,
5008                mcp: bamboo_domain::mcp_config::McpConfig::default(),
5009                notifications: NotificationsConfig::default(),
5010                connect: ConnectConfig::default(),
5011                plugin_trust: PluginTrustConfig::default(),
5012                extra: BTreeMap::new(),
5013            },
5014            None,
5015            SubagentsConfig::default(),
5016            ProviderConfigs::default(),
5017        )
5018    }
5019
5020    /// Get the full server address (bind:port)
5021    pub fn server_addr(&self) -> String {
5022        format!("{}:{}", self.server.bind, self.server.port)
5023    }
5024
5025    /// Save configuration to disk
5026    pub fn save(&self) -> Result<()> {
5027        self.save_to_dir(default_data_dir())
5028    }
5029
5030    /// Persist only the memory module, leaving every other config file untouched.
5031    /// Legacy layouts only; modular layouts require a revisioned section update.
5032    pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5033        self.memory.save_sync(data_dir)
5034    }
5035
5036    /// Persist only the sub-agent module, leaving every other config file untouched.
5037    /// Legacy layouts only; modular layouts require a revisioned section update.
5038    pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5039        self.subagents.save_sync(data_dir)
5040    }
5041
5042    /// Persist only provider configuration. Provider plaintext keys are first
5043    /// refreshed into their encrypted at-rest representation.
5044    /// Legacy layouts only; modular layouts require a revisioned section update.
5045    pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5046        crate::config_module::ensure_legacy_module_layout(data_dir)?;
5047        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5048        let mut config = self.clone();
5049        config.clear_legacy_provider_aliases_for_instance_mode();
5050        config.refresh_provider_api_keys_encrypted()?;
5051        config.providers.save_sync(data_dir)
5052    }
5053
5054    /// Build the metadata-only documents used by a provider credential
5055    /// transaction. Nothing is written here: the migration journal owns the
5056    /// durable commit and publishes both documents together with credentials.
5057    pub(crate) fn prepare_provider_transaction_documents(
5058        &self,
5059        provider_document: &[u8],
5060    ) -> Result<(Vec<u8>, Vec<u8>)> {
5061        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5062        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5063            anyhow::bail!(
5064                "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
5065                status.source
5066            );
5067        }
5068
5069        let mut to_save = self.clone();
5070        to_save.clear_legacy_provider_aliases_for_instance_mode();
5071        to_save.extra.remove("data_dir");
5072        to_save.extra.remove("model");
5073        to_save.refresh_encrypted_secrets()?;
5074        to_save.ensure_provider_instance_credentials_isolated()?;
5075        to_save.sanitize_mcp_credential_refs_for_disk();
5076        to_save.sanitize_env_vars_for_disk();
5077        to_save.sanitize_notifications_for_disk();
5078        to_save.sanitize_cluster_fabric_for_disk();
5079        to_save.assign_connect_platform_ids();
5080        to_save.normalize_tool_settings();
5081        to_save.normalize_skill_settings();
5082
5083        let mut root = durable_root_value(to_save.values.clone())
5084            .context("Failed to serialize root config DTO to JSON")?;
5085        if let Some(object) = root.as_object_mut() {
5086            object.remove("connect");
5087        }
5088        let root = serde_json::to_vec_pretty(&root)?;
5089
5090        let mut providers = to_save.providers.0.clone();
5091        macro_rules! sanitize_provider {
5092            ($field:ident) => {
5093                if let Some(provider) = providers.$field.as_mut() {
5094                    if !provider.api_key.trim().is_empty()
5095                        && !provider.api_key_from_env
5096                        && provider.credential_ref.is_none()
5097                    {
5098                        anyhow::bail!(
5099                            "provider secret requires credential transaction before persistence"
5100                        );
5101                    }
5102                    provider.api_key_encrypted = None;
5103                }
5104            };
5105        }
5106        sanitize_provider!(openai);
5107        sanitize_provider!(anthropic);
5108        sanitize_provider!(gemini);
5109        if let Some(provider) = providers.bodhi.as_mut() {
5110            if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
5111                anyhow::bail!("provider secret requires credential transaction before persistence");
5112            }
5113            provider.api_key_encrypted = None;
5114        }
5115
5116        let existing_provider_value = if provider_document.is_empty() {
5117            None
5118        } else {
5119            Some(
5120                serde_json::from_slice::<Value>(provider_document)
5121                    .context("provider metadata document is invalid")?,
5122            )
5123        };
5124        let provider_value = match existing_provider_value {
5125            Some(Value::Object(mut envelope))
5126                if envelope.contains_key("schema_version")
5127                    || envelope.contains_key("revision")
5128                    || envelope.contains_key("data") =>
5129            {
5130                let revision = envelope
5131                    .get("revision")
5132                    .and_then(Value::as_u64)
5133                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5134                let schema_version = envelope
5135                    .get("schema_version")
5136                    .and_then(Value::as_u64)
5137                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5138                if schema_version != 1 || !envelope.contains_key("data") {
5139                    anyhow::bail!("provider revision envelope is unsupported");
5140                }
5141                let revision = revision
5142                    .checked_add(1)
5143                    .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
5144                envelope.insert("revision".into(), Value::from(revision));
5145                envelope.insert("data".into(), serde_json::to_value(providers)?);
5146                Value::Object(envelope)
5147            }
5148            Some(_) | None => serde_json::to_value(providers)?,
5149        };
5150        Ok((root, serde_json::to_vec_pretty(&provider_value)?))
5151    }
5152
5153    /// The pending config-corruption recovery, if `config.json` failed to
5154    /// parse on load and the recovery hasn't been confirmed yet. `None` on
5155    /// every clean load. #153.
5156    pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
5157        self.recovery_status.as_ref()
5158    }
5159
5160    /// Confirm a pending recovery, allowing the next [`Config::save`] /
5161    /// [`Config::save_to_dir`] to overwrite the quarantined-corrupt
5162    /// `config.json` with this recovered state. No-op if there's no pending
5163    /// recovery. Prefer [`Config::confirm_recovery_and_save_to_dir`], which
5164    /// also persists and clears the flag in one step. #153.
5165    pub fn confirm_recovery(&mut self) {
5166        if let Some(status) = self.recovery_status.as_mut() {
5167            status.confirmed = true;
5168        }
5169    }
5170
5171    /// Confirm a pending recovery AND persist it in one step: marks it
5172    /// confirmed (satisfying the [`Config::save_to_dir`] guard), writes the
5173    /// recovered state to `config.json`, then clears `recovery_status`
5174    /// entirely — once this succeeds the config is no longer "pending
5175    /// confirmation", it's just the normal on-disk config. Errors (and
5176    /// leaves `recovery_status` untouched) if there's nothing pending, or if
5177    /// the save itself fails. #153.
5178    pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
5179        if self.recovery_status.is_none() {
5180            anyhow::bail!("No pending config-corruption recovery to confirm");
5181        }
5182        self.confirm_recovery();
5183        self.save_to_dir(data_dir)?;
5184        self.recovery_status = None;
5185        Ok(())
5186    }
5187
5188    /// Assign a stable [`ConnectPlatformConfig::id`] to every `connect.platforms`
5189    /// entry that doesn't already have one (#496).
5190    ///
5191    /// Migration-on-write: [`Config::save_to_dir`] always calls this on its
5192    /// internal save-copy before persisting, so every path that writes
5193    /// `connect.json` gets ids backfilled. Callers that mutate the *live*
5194    /// in-memory config as part of a save (e.g. the server's settings-PATCH
5195    /// handler) should also call this directly on that in-memory value
5196    /// before responding, so a client that echoes the response straight
5197    /// back round-trips the id immediately rather than only after the next
5198    /// reload/restart. Never called from load — a config that's never saved
5199    /// again (e.g. one sitting in an unconfirmed-recovery state, see #493)
5200    /// is never rewritten just to backfill ids. An entry that already has
5201    /// an id keeps it unchanged; ids are never reassigned or deduplicated
5202    /// once set.
5203    pub fn assign_connect_platform_ids(&mut self) {
5204        for platform in &mut self.connect.platforms {
5205            if platform.id.is_none() {
5206                platform.id = Some(uuid::Uuid::new_v4().to_string());
5207            }
5208        }
5209    }
5210
5211    /// Save configuration to disk under the provided data directory.
5212    ///
5213    /// Root configuration is stored as `{data_dir}/config.json`; extracted
5214    /// memory, sub-agent, and provider modules are stored in sibling sidecars.
5215    ///
5216    /// Refuses to write when this config carries an unconfirmed
5217    /// [`ConfigRecoveryStatus`] (#153) — i.e. it was recovered from a corrupt
5218    /// `config.json` and the recovery hasn't been confirmed — so a corrupt
5219    /// original a user might want to hand-fix is never silently clobbered by
5220    /// an auto-persisted recovery. Call [`Config::confirm_recovery`] (or
5221    /// [`Config::confirm_recovery_and_save_to_dir`]) first.
5222    pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5223        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5224        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5225            anyhow::bail!(
5226                "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5227                 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5228                 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5229                status.source,
5230                status.quarantine_path,
5231            );
5232        }
5233        if self.proxy_auth_credential_ref.is_none()
5234            && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5235        {
5236            anyhow::bail!(
5237                "proxy auth requires the isolated credential transaction before persistence"
5238            );
5239        }
5240        if self.env_vars.iter().any(|entry| {
5241            entry.secret
5242                && entry.credential_ref.is_none()
5243                && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5244        }) {
5245            anyhow::bail!(
5246                "secret env vars require the isolated credential transaction before persistence"
5247            );
5248        }
5249        if [
5250            (
5251                &self.notifications.ntfy.token,
5252                &self.notifications.ntfy.token_encrypted,
5253                &self.notifications.ntfy.credential_ref,
5254            ),
5255            (
5256                &self.notifications.bark.device_key,
5257                &self.notifications.bark.device_key_encrypted,
5258                &self.notifications.bark.credential_ref,
5259            ),
5260        ]
5261        .iter()
5262        .any(|(plaintext, ciphertext, reference)| {
5263            reference.is_none()
5264                && (plaintext
5265                    .as_deref()
5266                    .is_some_and(|value| !value.trim().is_empty())
5267                    || ciphertext.is_some())
5268        }) {
5269            anyhow::bail!(
5270                "notification secrets require the isolated credential transaction before persistence"
5271            );
5272        }
5273
5274        if crate::modular_authority_boundary_present(&data_dir)
5275            .context("Failed to inspect modular configuration authority boundary")?
5276        {
5277            crate::ConfigFacade::open_or_migrate(&data_dir)
5278                .context("Failed to recover modular configuration before persistence")?;
5279            crate::persist_facade_effective_config(&data_dir, self)
5280                .context("Failed to persist modular configuration sections")?;
5281            return Ok(());
5282        }
5283
5284        if crate::section_layout_is_active(&data_dir)
5285            .context("Failed to inspect modular configuration layout")?
5286        {
5287            crate::persist_facade_effective_config(&data_dir, self)
5288                .context("Failed to persist modular configuration sections")?;
5289            return Ok(());
5290        }
5291
5292        let path = data_dir.join("config.json");
5293
5294        if let Some(parent) = path.parent() {
5295            std::fs::create_dir_all(parent)
5296                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5297        }
5298
5299        let mut to_save = self.clone();
5300        to_save.clear_legacy_provider_aliases_for_instance_mode();
5301        // Never persist `data_dir` into config.json (data dir is runtime-derived).
5302        to_save.extra.remove("data_dir");
5303        // Root-level `model` is deprecated; do not persist it.
5304        to_save.extra.remove("model");
5305        // `subagents.broker` is `#[serde(skip)]` (runtime-only, lives in its own
5306        // broker.json / embedded in-process) — nothing to encrypt or persist here.
5307        to_save.refresh_encrypted_secrets()?;
5308        to_save.ensure_provider_instance_credentials_isolated()?;
5309        to_save.sanitize_mcp_credential_refs_for_disk();
5310        to_save.sanitize_env_vars_for_disk();
5311        to_save.sanitize_notifications_for_disk();
5312        to_save.sanitize_cluster_fabric_for_disk();
5313        to_save.assign_connect_platform_ids();
5314        to_save.normalize_tool_settings();
5315        to_save.normalize_skill_settings();
5316
5317        // Split `connect` (#455) out of the config.json document: bamboo-connect
5318        // platform-bridge credentials (bot tokens, allowlists) get their own
5319        // sibling file, connect.json (written below), instead of living in
5320        // config.json — different sensitivity/lifecycle. The `connect` FIELD on
5321        // `Config` keeps its normal serde shape unchanged (still required by the
5322        // settings API / `preserve_masked_connect_secrets`, which operate on the
5323        // in-memory struct) — only the serialized DOCUMENT that becomes
5324        // config.json's bytes has the key stripped, and that's done on the
5325        // `serde_json::Value` here, not via `#[serde(skip)]` on the field.
5326        let mut config_value = durable_root_value(to_save.values.clone())
5327            .context("Failed to serialize root config DTO to JSON")?;
5328        if let Some(obj) = config_value.as_object_mut() {
5329            obj.remove("connect");
5330        }
5331        let content = serde_json::to_string_pretty(&config_value)
5332            .context("Failed to serialize config to JSON")?;
5333
5334        // Persist extracted modules before stripping their legacy inline
5335        // representation from config.json. If the process crashes or the root
5336        // rewrite fails during the first migration, the next load can still use
5337        // either the new sidecars or the untouched inline values. Do this before
5338        // rotating root backups so a sidecar error cannot consume backup history
5339        // for a root document that was never rewritten.
5340        to_save.memory.save_sync(&data_dir)?;
5341        to_save.subagents.save_sync(&data_dir)?;
5342        to_save.providers.save_sync(&data_dir)?;
5343
5344        // Back up the current on-disk config (last-known-good) before overwriting,
5345        // so corruption (a bad/partial write, external edit, disk issue) stays
5346        // recoverable via config.json.bak on the next load. Best-effort. Only
5347        // refresh the backup from a PARSEABLE config.json — otherwise a save right
5348        // after an in-memory recovery (where the on-disk config.json is still the
5349        // corrupt original) would clobber the good .bak with garbage. #37.
5350        if path.exists()
5351            && std::fs::read_to_string(&path)
5352                .ok()
5353                .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5354        {
5355            // Rotate the older generations down (.bak -> .bak.1 -> .bak.2 …) so a
5356            // few last-known-good snapshots survive, then snapshot the current
5357            // (parseable) config.json as the freshest .bak. #135.
5358            rotate_backups(&path, BAK_GENERATIONS);
5359            let backup = backup_path_for(&path, 0);
5360            let backup_result = std::fs::read(&path).and_then(|bytes| {
5361                let mut value: Value = serde_json::from_slice(&bytes)
5362                    .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5363                if sanitize_ref_backed_mcp_json(&mut value) {
5364                    let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5365                        std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5366                    })?;
5367                    write_atomic(&backup, &sanitized)
5368                } else {
5369                    std::fs::copy(&path, &backup).map(|_| ())
5370                }
5371            });
5372            if let Err(e) = backup_result {
5373                tracing::warn!("Failed to back up config.json before save: {}", e);
5374            }
5375            scrub_ref_backed_mcp_from_config_backups(&path);
5376        }
5377
5378        write_atomic(&path, content.as_bytes())
5379            .with_context(|| format!("Failed to write config file: {:?}", path))?;
5380
5381        save_connect_config(&to_save.connect, &data_dir)?;
5382
5383        Ok(())
5384    }
5385}
5386
5387/// Remove credential-ref-backed MCP values from a raw root document without
5388/// otherwise normalizing its compatibility shape. This is used for rotated
5389/// root backups as well as the typed disk DTO so a pre-fix root cannot keep a
5390/// duplicate secret alive for several more saves.
5391fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5392    let Some(object) = root.as_object_mut() else {
5393        return false;
5394    };
5395    let Some(mcp) = (if object.contains_key("mcpServers") {
5396        object.get_mut("mcpServers")
5397    } else {
5398        object.get_mut("mcp")
5399    }) else {
5400        return false;
5401    };
5402    let mut changed = false;
5403    if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5404        for server in servers {
5405            if let Some(object) = server.as_object_mut() {
5406                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5407                {
5408                    changed |= sanitize_ref_backed_mcp_transport(transport);
5409                }
5410            }
5411        }
5412    } else if let Some(servers) = mcp.as_object_mut() {
5413        for server in servers.values_mut() {
5414            if let Some(object) = server.as_object_mut() {
5415                changed |= sanitize_ref_backed_mcp_transport(object);
5416                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5417                {
5418                    changed |= sanitize_ref_backed_mcp_transport(transport);
5419                }
5420            }
5421        }
5422    }
5423    changed
5424}
5425
5426fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5427    let mut changed = false;
5428    let env_names = object
5429        .get("env_credential_refs")
5430        .and_then(Value::as_object)
5431        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5432        .unwrap_or_default();
5433    for name in env_names {
5434        for field in ["env", "env_encrypted"] {
5435            if object
5436                .get_mut(field)
5437                .and_then(Value::as_object_mut)
5438                .and_then(|values| values.remove(&name))
5439                .is_some()
5440            {
5441                changed = true;
5442            }
5443        }
5444    }
5445    let header_names = object
5446        .get("header_credential_refs")
5447        .and_then(Value::as_object)
5448        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5449        .unwrap_or_default();
5450    for name in header_names {
5451        for field in ["headers", "headers_encrypted"] {
5452            if object
5453                .get_mut(field)
5454                .and_then(Value::as_object_mut)
5455                .and_then(|values| values.remove(&name))
5456                .is_some()
5457            {
5458                changed = true;
5459            }
5460        }
5461    }
5462    if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5463        for header in headers {
5464            let Some(header) = header.as_object_mut() else {
5465                continue;
5466            };
5467            if header.get("credential_ref").is_some_and(Value::is_string) {
5468                changed |= header.remove("value").is_some();
5469                changed |= header.remove("value_encrypted").is_some();
5470            }
5471        }
5472    }
5473    changed
5474}
5475
5476fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5477    for generation in 0..BAK_GENERATIONS {
5478        let backup = backup_path_for(path, generation);
5479        let Ok(bytes) = std::fs::read(&backup) else {
5480            continue;
5481        };
5482        let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5483            continue;
5484        };
5485        if sanitize_ref_backed_mcp_json(&mut value) {
5486            if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5487                if let Err(error) = write_atomic(&backup, &sanitized) {
5488                    tracing::warn!(
5489                        "Failed to scrub MCP credentials from {:?}: {}",
5490                        backup,
5491                        error
5492                    );
5493                }
5494            }
5495        }
5496    }
5497}
5498
5499/// Persist `connect` (#455) to its own sibling file, `connect.json`, next to
5500/// config.json — the save-side counterpart of [`Config::merge_connect_config`].
5501///
5502/// Only writes when the config is non-empty OR the file already exists, so a
5503/// fresh/default install with no platforms configured never gets a
5504/// `connect.json` littering its data dir. Before an existing file is
5505/// overwritten, it's copied aside to a single `connect.json.bak` generation
5506/// (best-effort) — connect.json doesn't need config.json's multi-generation
5507/// rotation, one last-known-good snapshot is enough.
5508fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5509    let path = data_dir.join("connect.json");
5510    if connect_config_is_empty(connect) && !path.exists() {
5511        return Ok(());
5512    }
5513
5514    if path.exists() {
5515        let backup = path.with_extension("json.bak");
5516        if let Err(e) = std::fs::copy(&path, &backup) {
5517            tracing::warn!("Failed to back up connect.json before save: {}", e);
5518        }
5519    }
5520
5521    let content = serde_json::to_string_pretty(connect)
5522        .context("Failed to serialize connect config to JSON")?;
5523    write_atomic(&path, content.as_bytes())
5524        .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5525    Ok(())
5526}
5527
5528/// Remove the legacy inline `connect` key from `config.json` on disk, if
5529/// present — the narrow, load-side counterpart of the full-document rewrite
5530/// [`Config::save_to_dir`] would otherwise perform just to drop one stale
5531/// key. Used by [`Config::merge_connect_config`] both when adopting a
5532/// pure-legacy `connect` key (migration) and when a stale legacy key lingers
5533/// alongside an authoritative connect.json. #457.
5534///
5535/// Operates on the raw `serde_json::Value` read straight from disk — NOT on
5536/// the typed `Config` — so it touches nothing but the one key: no other
5537/// secret gets re-encrypted, and no `config.json.bak` generation gets
5538/// rotated, as a side effect of a load.
5539///
5540/// Best-effort: read/parse/write failures are logged, not propagated — this
5541/// runs as a side effect of `Config::new()` / load, which has no `Result` to
5542/// surface it through. A failure here just leaves the stale key in place
5543/// until the next natural save; connect.json (written separately) is already
5544/// authoritative in memory either way.
5545fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5546    let config_path = data_dir.join("config.json");
5547    let content = match std::fs::read_to_string(&config_path) {
5548        Ok(content) => content,
5549        Err(e) => {
5550            tracing::error!(
5551                "Failed to read config.json to strip legacy `connect` key: {}",
5552                e
5553            );
5554            return;
5555        }
5556    };
5557    let mut value: serde_json::Value = match serde_json::from_str(&content) {
5558        Ok(value) => value,
5559        Err(e) => {
5560            tracing::error!(
5561                "Failed to parse config.json to strip legacy `connect` key: {}",
5562                e
5563            );
5564            return;
5565        }
5566    };
5567    let Some(obj) = value.as_object_mut() else {
5568        return;
5569    };
5570    if obj.remove("connect").is_none() {
5571        // Nothing to strip (e.g. raced with a concurrent save that already
5572        // dropped it) — avoid an unnecessary rewrite.
5573        return;
5574    }
5575    let rewritten = match serde_json::to_string_pretty(&value) {
5576        Ok(rewritten) => rewritten,
5577        Err(e) => {
5578            tracing::error!(
5579                "Failed to serialize config.json after stripping legacy `connect` key: {}",
5580                e
5581            );
5582            return;
5583        }
5584    };
5585    if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5586        tracing::error!(
5587            "Failed to write config.json after stripping legacy `connect` key: {}",
5588            e
5589        );
5590    }
5591}
5592
5593/// Sweep the rotated `config.json.bak[.N]` generations for a legacy embedded
5594/// `connect` sub-tree that predates the #455 connect.json split, and strip it
5595/// in place. #468 (follow-up to #457).
5596///
5597/// `strip_legacy_connect_key_from_config_json` only ever rewrites the CURRENT
5598/// `config.json` — it never reaches into `.bak` generations, and the normal
5599/// backup-rotation path (see [`rotate_backups`]) only overwrites a `.bak[.N]`
5600/// slot as a side effect of a fresh SAVE. An instance that upgraded from a
5601/// pre-#455 build but rarely (or never) triggers a config save can therefore
5602/// carry the legacy, encrypted `connect` sub-tree — including bot tokens, an
5603/// immediately-usable remote-control credential — in an old backup generation
5604/// indefinitely, even after its live config.json has long since been
5605/// migrated.
5606///
5607/// Deliberately surgical, mirroring the `.bak` files' role as the user's
5608/// recovery net (#493's "backups are a low-sensitivity snapshot, don't fuss
5609/// with them" posture):
5610/// - a generation that doesn't exist, or that fails to parse as JSON, is
5611///   SKIPPED — logged, never deleted, never guessed at. Corrupt/foreign
5612///   content in a `.bak` slot is left exactly as found for hand inspection.
5613/// - a generation that parses but carries no `connect` key (the overwhelming
5614///   majority, especially on any instance that predates this fix by more
5615///   than `BAK_GENERATIONS` saves) is left COMPLETELY untouched — not even a
5616///   byte-identical rewrite — so its mtime and on-disk bytes survive.
5617/// - only a generation that actually parses AND carries the legacy key gets
5618///   rewritten, via the same key-removal-on-the-raw-`Value` + `write_atomic`
5619///   approach as `strip_legacy_connect_key_from_config_json`, so every other
5620///   byte of that snapshot (all other settings, formatting aside) survives.
5621///
5622/// Runs unconditionally on every load (not gated on the CURRENT config.json
5623/// still carrying the legacy key) specifically to catch already-migrated
5624/// installs whose backups predate this fix. Cheap: at most `BAK_GENERATIONS`
5625/// small file reads, and a genuine no-op (zero writes) once every generation
5626/// has been swept once. Best-effort like its sibling: failures are logged,
5627/// not propagated, since this runs as a side effect of `Config::new()` /
5628/// load, which has no `Result` to surface it through.
5629fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5630    let config_path = data_dir.join("config.json");
5631    for gen in 0..BAK_GENERATIONS {
5632        let backup = backup_path_for(&config_path, gen);
5633        let content = match std::fs::read_to_string(&backup) {
5634            Ok(content) => content,
5635            Err(e) => {
5636                if e.kind() != std::io::ErrorKind::NotFound {
5637                    tracing::warn!(
5638                        "Failed to read {:?} while scanning for legacy connect data ({}); \
5639                         leaving it untouched",
5640                        backup,
5641                        e
5642                    );
5643                }
5644                continue;
5645            }
5646        };
5647        let mut value: serde_json::Value = match serde_json::from_str(&content) {
5648            Ok(value) => value,
5649            Err(e) => {
5650                tracing::warn!(
5651                    "Skipping unparsable backup {:?} while scanning for legacy connect data \
5652                     ({}); left untouched (never deleted)",
5653                    backup,
5654                    e
5655                );
5656                continue;
5657            }
5658        };
5659        let Some(obj) = value.as_object_mut() else {
5660            // Not a JSON object (e.g. `null`/an array) — nothing to strip, and
5661            // not a shape we should try to rewrite. Leave it alone.
5662            continue;
5663        };
5664        if obj.remove("connect").is_none() {
5665            // No legacy key in this generation — skip without writing so the
5666            // file's bytes/mtime are left completely untouched.
5667            continue;
5668        }
5669        let rewritten = match serde_json::to_string_pretty(&value) {
5670            Ok(rewritten) => rewritten,
5671            Err(e) => {
5672                tracing::error!(
5673                    "Failed to serialize {:?} after stripping legacy connect data: {}",
5674                    backup,
5675                    e
5676                );
5677                continue;
5678            }
5679        };
5680        match write_atomic(&backup, rewritten.as_bytes()) {
5681            Ok(()) => tracing::info!(
5682                "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5683                backup
5684            ),
5685            Err(e) => tracing::error!(
5686                "Failed to write {:?} after stripping legacy connect data: {}",
5687                backup,
5688                e
5689            ),
5690        }
5691    }
5692}
5693
5694/// Quarantine an unparsable `connect.json` to a single `connect.json.bak`
5695/// generation (best-effort) so the bad content survives for inspection
5696/// instead of being silently discarded. Unlike config.json's timestamped,
5697/// N-generation quarantine, connect.json only needs one slot — it's a much
5698/// smaller, less complex document and this is a fail-SAFE (empty/inert
5699/// bridge), not a fail-recover, posture. #455.
5700///
5701/// MOVES the corrupt file rather than copying it (#457): a copy would leave
5702/// the same corrupt `connect.json` sitting in the data dir right next to its
5703/// own quarantine copy, which reads as confusing/ambiguous mid-incident
5704/// (which one is live?). `rename` is used first (atomic, no partial-copy
5705/// window); if that fails — e.g. `connect.json.bak` and the data dir are on
5706/// different filesystems — fall back to copy-then-remove so the corrupt
5707/// original still doesn't linger.
5708fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5709    let backup = connect_path.with_extension("json.bak");
5710    match std::fs::rename(connect_path, &backup) {
5711        Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5712        Err(e) => {
5713            tracing::warn!(
5714                "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5715                backup,
5716                e
5717            );
5718            if let Err(e) = std::fs::copy(connect_path, &backup) {
5719                tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5720                return;
5721            }
5722            if let Err(e) = std::fs::remove_file(connect_path) {
5723                tracing::error!(
5724                    "Quarantined corrupt connect.json to {:?} but failed to remove the \
5725                     original {:?}: {}",
5726                    backup,
5727                    connect_path,
5728                    e
5729                );
5730            }
5731        }
5732    }
5733}
5734
5735/// How many `config.json.corrupted.*` quarantine files to keep. Each corrupt load
5736/// drops one; without a cap they accumulate unbounded. Newest `N` are retained.
5737const QUARANTINE_KEEP: usize = 5;
5738
5739/// Copy a corrupt config file aside to `config.json.corrupted.<nanos>` so the
5740/// user's (unparseable) configuration is preserved for inspection/recovery
5741/// instead of being silently discarded and then overwritten by defaults. #37.
5742///
5743/// Returns the quarantine path on success, so the caller can attach it to a
5744/// [`ConfigRecoveryStatus`] (#153); `None` if even the copy failed (the
5745/// corrupt original is still left in place at `config_path` regardless, since
5746/// this only ever copies, never moves/deletes).
5747fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5748    let nanos = std::time::SystemTime::now()
5749        .duration_since(std::time::UNIX_EPOCH)
5750        .map(|d| d.as_nanos())
5751        .unwrap_or(0);
5752    // Two corrupt loads in the same nanosecond would land on the same name and the
5753    // second `copy` would silently overwrite the first. Append a counter on
5754    // collision so each quarantine is preserved distinctly. #135.
5755    let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5756    let mut dedup = 1u32;
5757    while quarantine.exists() {
5758        quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5759        dedup += 1;
5760    }
5761    let result = match std::fs::copy(config_path, &quarantine) {
5762        Ok(_) => {
5763            tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5764            Some(quarantine)
5765        }
5766        Err(e) => {
5767            tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5768            None
5769        }
5770    };
5771    prune_quarantine_files(config_path, QUARANTINE_KEEP);
5772    result
5773}
5774
5775/// Keep only the newest `keep` `config.json.corrupted.*` files next to
5776/// `config_path`, deleting older ones so quarantines don't grow unbounded. #135.
5777fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5778    let Some(dir) = config_path.parent() else {
5779        return;
5780    };
5781    let prefix = "config.json.corrupted.";
5782    let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5783        Ok(entries) => entries
5784            .filter_map(|e| e.ok())
5785            .map(|e| e.path())
5786            .filter(|p| {
5787                p.file_name()
5788                    .and_then(|n| n.to_str())
5789                    .is_some_and(|n| n.starts_with(prefix))
5790            })
5791            .collect(),
5792        Err(_) => return,
5793    };
5794    if quarantines.len() <= keep {
5795        return;
5796    }
5797    // Oldest first (by mtime; missing mtime sorts oldest so it's pruned first).
5798    quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5799    let remove = quarantines.len() - keep;
5800    for stale in quarantines.into_iter().take(remove) {
5801        if let Err(e) = std::fs::remove_file(&stale) {
5802            tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5803        }
5804    }
5805}
5806
5807/// Number of `config.json.bak[.N]` generations to retain (`.bak` + `N-1` numbered).
5808/// More generations = more recovery points if a fresher backup is itself bad. #135.
5809const BAK_GENERATIONS: usize = 3;
5810
5811/// The on-disk path of backup generation `gen` (0 == `config.json.bak`).
5812fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5813    if gen == 0 {
5814        config_path.with_extension("json.bak")
5815    } else {
5816        config_path.with_extension(format!("json.bak.{gen}"))
5817    }
5818}
5819
5820/// Shift the backup generations down before a fresh `.bak` is written:
5821/// `.bak.(N-2) -> .bak.(N-1)`, …, `.bak -> .bak.1`. The oldest is overwritten by
5822/// the shift; the caller then writes the new `.bak`. Walks the highest (oldest)
5823/// destination slot first so no rename clobbers a slot a later move still needs to
5824/// read. Best-effort. #135.
5825fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5826    for gen in (1..generations).rev() {
5827        let from = backup_path_for(config_path, gen - 1);
5828        let to = backup_path_for(config_path, gen);
5829        if from.exists() {
5830            if let Err(e) = std::fs::rename(&from, &to) {
5831                tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5832            }
5833        }
5834    }
5835}
5836
5837pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5838    crate::config_store::AtomicFileStore::new(path)
5839        .write_bytes_without_backup(content)
5840        .map_err(|error| match error {
5841            crate::config_store::ConfigStoreError::Io(error) => error,
5842            other => std::io::Error::other(other),
5843        })
5844}
5845
5846#[cfg(test)]
5847mod tests {
5848    use super::*;
5849    use std::ffi::OsString;
5850    use std::path::PathBuf;
5851    use std::sync::Mutex;
5852    use std::time::{SystemTime, UNIX_EPOCH};
5853
5854    #[test]
5855    fn tools_config_preserves_unknown_keys_across_roundtrip() {
5856        let input = serde_json::json!({
5857            "disabled": ["bash"],
5858            "plugin_runtime": {
5859                "timeout_ms": 5_000,
5860                "sandbox": true
5861            },
5862            "future_flag": "enabled"
5863        });
5864
5865        let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5866        assert_eq!(config.disabled, vec!["bash"]);
5867        assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5868        assert_eq!(config.extra["future_flag"], "enabled");
5869        assert_eq!(serde_json::to_value(config).unwrap(), input);
5870    }
5871
5872    #[test]
5873    fn tools_config_keeps_section_when_only_unknown_keys_present() {
5874        let input = serde_json::json!({
5875            "tool_extension": {
5876                "mode": "strict",
5877                "options": ["one", "two"]
5878            }
5879        });
5880        let config: Config = serde_json::from_value(serde_json::json!({
5881            "tools": input.clone()
5882        }))
5883        .unwrap();
5884
5885        assert!(config.tools.disabled.is_empty());
5886        assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5887        assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5888
5889        let temp_home = TempHome::new();
5890        config.save_to_dir(temp_home.path.clone()).unwrap();
5891        let persisted: Value =
5892            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5893                .unwrap();
5894        assert_eq!(persisted["tools"], input);
5895    }
5896
5897    #[test]
5898    fn skills_config_preserves_unknown_keys_across_roundtrip() {
5899        let input = serde_json::json!({
5900            "external_catalog": {
5901                "path": "/opt/bamboo/skills",
5902                "refresh": false
5903            },
5904            "schema_version": 2
5905        });
5906
5907        let config: Config = serde_json::from_value(serde_json::json!({
5908            "skills": input.clone()
5909        }))
5910        .unwrap();
5911        assert!(config.skills.disabled.is_empty());
5912        assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5913        assert_eq!(config.skills.extra["schema_version"], 2);
5914        assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5915
5916        let temp_home = TempHome::new();
5917        config.save_to_dir(temp_home.path.clone()).unwrap();
5918        let persisted: Value =
5919            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5920                .unwrap();
5921        assert_eq!(persisted["skills"], input);
5922    }
5923
5924    #[test]
5925    fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5926        let config: Config = serde_json::from_value(serde_json::json!({
5927            "hooks": {
5928                "image_fallback": {"enabled": true, "mode": "placeholder"}
5929            },
5930            "lifecycle_hooks": {
5931                "enabled": true,
5932                "PreToolUse": [{
5933                    "matcher": "bash|write_file",
5934                    "hooks": [{"type": "command", "command": "guard.sh"}]
5935                }],
5936                "SessionStart": [{
5937                    "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5938                }]
5939            }
5940        }))
5941        .expect("lifecycle hook config should deserialize");
5942
5943        assert!(config.lifecycle_hooks.enabled);
5944        assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5945        assert!(
5946            config.lifecycle_hooks.pre_tool_use[0].enabled,
5947            "legacy groups without an enabled flag remain active"
5948        );
5949        assert_eq!(
5950            config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5951            DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5952        );
5953        assert_eq!(
5954            config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5955            25
5956        );
5957
5958        let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5959        assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5960        assert_eq!(
5961            json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5962            "bash|write_file"
5963        );
5964        assert_eq!(
5965            json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5966            "command"
5967        );
5968        assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5969            .get("enabled")
5970            .is_none());
5971        assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5972            .get("timeout_ms")
5973            .is_none());
5974        assert!(json.get("hooks").is_some());
5975    }
5976
5977    #[test]
5978    fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5979        let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5980            "type": "script",
5981            "path": ".bamboo/hooks/check.js"
5982        }))
5983        .expect("script lifecycle hook should deserialize");
5984
5985        assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5986        assert!(matches!(
5987            handler,
5988            LifecycleHookHandler::Script {
5989                runner: LifecycleScriptRunner::Auto,
5990                ..
5991            }
5992        ));
5993        let json = serde_json::to_value(handler).expect("script hook should serialize");
5994        assert_eq!(json["type"], "script");
5995        assert!(json.get("timeout_ms").is_none());
5996        assert!(json.get("runner").is_none());
5997    }
5998
5999    #[test]
6000    fn script_runner_support_is_extension_aware() {
6001        assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
6002        assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
6003        assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
6004        assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
6005        assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
6006        assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
6007        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
6008        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
6009        assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
6010        assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
6011    }
6012
6013    #[test]
6014    fn script_runner_names_round_trip_through_config_json() {
6015        for (runner, name) in [
6016            (LifecycleScriptRunner::Auto, "auto"),
6017            (LifecycleScriptRunner::Node, "node"),
6018            (LifecycleScriptRunner::Bun, "bun"),
6019            (LifecycleScriptRunner::Python, "python"),
6020            (LifecycleScriptRunner::Bash, "bash"),
6021            (LifecycleScriptRunner::PowerShell, "powershell"),
6022            (LifecycleScriptRunner::Cmd, "cmd"),
6023        ] {
6024            let json = serde_json::to_value(runner).unwrap();
6025            assert_eq!(json, name);
6026            assert_eq!(
6027                serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
6028                runner
6029            );
6030        }
6031    }
6032
6033    #[test]
6034    fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
6035        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6036        assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
6037        let json = serde_json::to_value(&config).unwrap();
6038        assert!(json.get("lifecycle_hooks").is_none());
6039    }
6040
6041    #[test]
6042    fn stream_timeout_defaults_are_safe_and_back_compatible() {
6043        let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6044            .expect("legacy config without stream_timeout should load");
6045        let values = ConfigValues::from(root);
6046
6047        assert_eq!(
6048            values.stream_timeout,
6049            StreamTimeoutConfig {
6050                transport_idle_timeout_secs: 120,
6051                first_semantic_timeout_secs: 600,
6052                semantic_idle_timeout_secs: 600,
6053            }
6054        );
6055        values
6056            .stream_timeout
6057            .validate()
6058            .expect("defaults are valid");
6059    }
6060
6061    #[test]
6062    fn llm_retry_defaults_load_from_a_config_without_the_key_and_tolerate_slow_routes() {
6063        let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6064            .expect("legacy config without llm_retry should load");
6065        let values = ConfigValues::from(root);
6066        // Unset means "never configured": the environment-derived policy keeps
6067        // applying, which is what an existing deployment expects on upgrade.
6068        assert_eq!(values.llm_retry, None);
6069
6070        let defaults = LlmRetryConfig::default();
6071        assert_eq!(defaults.max_attempts, 8);
6072        assert_eq!(defaults.attempt_timeout_ms, 180_000);
6073        assert_eq!(defaults.total_budget_ms, 900_000);
6074        defaults.validate().expect("defaults are valid");
6075    }
6076
6077    #[test]
6078    fn llm_retry_rejects_out_of_range_or_inconsistent_policies() {
6079        let valid = LlmRetryConfig::default();
6080        assert!(valid.validate().is_ok());
6081        for broken in [
6082            LlmRetryConfig {
6083                max_attempts: 0,
6084                ..valid
6085            },
6086            LlmRetryConfig {
6087                max_attempts: MAX_LLM_RETRY_ATTEMPTS + 1,
6088                ..valid
6089            },
6090            LlmRetryConfig {
6091                attempt_timeout_ms: 999,
6092                ..valid
6093            },
6094            LlmRetryConfig {
6095                attempt_timeout_ms: MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS + 1,
6096                ..valid
6097            },
6098            LlmRetryConfig {
6099                total_budget_ms: 0,
6100                ..valid
6101            },
6102            LlmRetryConfig {
6103                total_budget_ms: MAX_LLM_RETRY_TOTAL_BUDGET_MS + 1,
6104                ..valid
6105            },
6106            // A base delay above the cap would never be honoured.
6107            LlmRetryConfig {
6108                base_delay_ms: 2_000,
6109                max_delay_ms: 1_000,
6110                ..valid
6111            },
6112        ] {
6113            assert!(broken.validate().is_err(), "{broken:?} must be rejected");
6114        }
6115    }
6116
6117    #[test]
6118    fn llm_retry_round_trips_through_its_section() {
6119        let policy = LlmRetryConfig {
6120            max_attempts: 12,
6121            attempt_timeout_ms: 300_000,
6122            total_budget_ms: 3_600_000,
6123            base_delay_ms: 250,
6124            max_delay_ms: 60_000,
6125        };
6126        let values = ConfigValues {
6127            llm_retry: Some(policy),
6128            ..ConfigValues::default()
6129        };
6130        let json = serde_json::to_value(&values).expect("serialize");
6131        assert_eq!(json["llm_retry"]["max_attempts"], 12);
6132        let round_tripped: ConfigValues = serde_json::from_value(json).expect("deserialize");
6133        assert_eq!(round_tripped.llm_retry, Some(policy));
6134    }
6135
6136    #[test]
6137    fn stream_timeout_round_trips_through_persistence_dto() {
6138        let values = ConfigValues {
6139            stream_timeout: StreamTimeoutConfig {
6140                transport_idle_timeout_secs: 45,
6141                first_semantic_timeout_secs: 900,
6142                semantic_idle_timeout_secs: 300,
6143            },
6144            ..ConfigValues::default()
6145        };
6146
6147        let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
6148        assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
6149        assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
6150        assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
6151
6152        let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
6153        assert_eq!(
6154            ConfigValues::from(root).stream_timeout,
6155            StreamTimeoutConfig {
6156                transport_idle_timeout_secs: 45,
6157                first_semantic_timeout_secs: 900,
6158                semantic_idle_timeout_secs: 300,
6159            }
6160        );
6161    }
6162
6163    #[test]
6164    fn context_management_defaults_to_summary_and_is_omitted() {
6165        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6166        assert_eq!(
6167            config.context_management.strategy,
6168            ContextManagementStrategy::Summary
6169        );
6170        assert_eq!(
6171            config
6172                .context_management
6173                .retrieval_window
6174                .min_recent_user_turns,
6175            3
6176        );
6177        assert_eq!(
6178            config.context_management.retrieval_target_usage_percent(),
6179            25
6180        );
6181        let json = serde_json::to_value(&config).unwrap();
6182        assert!(json.get("context_management").is_none());
6183    }
6184
6185    #[test]
6186    fn retrieval_window_context_management_round_trips() {
6187        let json = serde_json::json!({
6188            "context_management": {
6189                "strategy": "retrieval_window",
6190                "retrieval_window": {
6191                    "min_recent_user_turns": 4,
6192                    "trigger_usage_ratio": 0.82,
6193                    "target_usage_ratio": 0.61,
6194                    "history_tool_required": true,
6195                    "fallback_strategy": "summary"
6196                }
6197            }
6198        });
6199        let root: ConfigRoot = serde_json::from_value(json).unwrap();
6200        let values = ConfigValues::from(root);
6201        assert_eq!(
6202            values.context_management.strategy,
6203            ContextManagementStrategy::RetrievalWindow
6204        );
6205        assert_eq!(
6206            values.context_management.retrieval_window.fallback_strategy,
6207            ContextManagementFallbackStrategy::Summary
6208        );
6209
6210        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6211        assert_eq!(
6212            persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
6213            4
6214        );
6215        assert_eq!(
6216            persisted["context_management"]["strategy"],
6217            "retrieval_window"
6218        );
6219    }
6220
6221    #[test]
6222    fn message_summary_context_management_round_trips_without_changing_defaults() {
6223        let root: ConfigRoot = serde_json::from_value(serde_json::json!({
6224            "context_management": {
6225                "strategy": "message_summary"
6226            }
6227        }))
6228        .unwrap();
6229        let values = ConfigValues::from(root);
6230        assert_eq!(
6231            values.context_management.strategy,
6232            ContextManagementStrategy::MessageSummary
6233        );
6234        assert_eq!(
6235            values.context_management.retrieval_window,
6236            RetrievalWindowContextConfig::default()
6237        );
6238
6239        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6240        assert_eq!(
6241            persisted["context_management"]["strategy"],
6242            "message_summary"
6243        );
6244        let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
6245        assert_eq!(
6246            ConfigValues::from(reloaded).context_management.strategy,
6247            ContextManagementStrategy::MessageSummary
6248        );
6249        assert_eq!(
6250            ContextManagementConfig::default().strategy,
6251            ContextManagementStrategy::Summary
6252        );
6253    }
6254
6255    #[test]
6256    fn context_management_uses_one_explicit_policy_for_every_strategy() {
6257        for strategy in ["summary", "message_summary", "retrieval_window"] {
6258            let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6259                "strategy": strategy,
6260                "trigger_usage_ratio": 0.72,
6261                "target_usage_ratio": 0.20,
6262                "retrieval_window": {"trigger_usage_ratio": 0.9, "target_usage_ratio": 0.6}
6263            }))
6264            .unwrap();
6265            let budget =
6266                policy.apply_compression_policy(bamboo_domain::TokenBudget::for_model(200_000));
6267            assert_eq!(budget.compression_trigger_percent, 72);
6268            assert_eq!(budget.compression_target_percent, 20);
6269            let saved = serde_json::to_value(&policy).unwrap();
6270            let restored: ContextManagementConfig = serde_json::from_value(saved).unwrap();
6271            assert_eq!(restored, policy);
6272        }
6273    }
6274
6275    #[test]
6276    fn context_management_migrates_stricter_legacy_ratios_without_preserving_old_sixty_percent_target(
6277    ) {
6278        for strategy in ["summary", "message_summary", "retrieval_window"] {
6279            for (trigger, target, expected_trigger, expected_target) in
6280                [(0.80, 0.60, 0.80, 0.25), (0.65, 0.15, 0.65, 0.15)]
6281            {
6282                let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6283                    "strategy": strategy,
6284                    "retrieval_window": {"trigger_usage_ratio": trigger, "target_usage_ratio": target}
6285                })).unwrap();
6286                assert_eq!(policy.trigger_usage_ratio, expected_trigger);
6287                assert_eq!(policy.target_usage_ratio, expected_target);
6288            }
6289        }
6290    }
6291
6292    #[test]
6293    fn context_management_rejects_unrepresentable_shared_range() {
6294        for (trigger, target) in [(0.80, 0.80), (1.01, 0.25), (0.015, 0.01), (0.80, 0.005)] {
6295            let value =
6296                serde_json::json!({"trigger_usage_ratio": trigger, "target_usage_ratio": target});
6297            assert!(serde_json::from_value::<ContextManagementConfig>(value).is_err());
6298        }
6299    }
6300
6301    #[test]
6302    fn retrieval_window_context_management_rejects_unsafe_policy() {
6303        for invalid in [
6304            serde_json::json!({
6305                "strategy": "retrieval_window",
6306                "retrieval_window": {"min_recent_user_turns": 0}
6307            }),
6308            serde_json::json!({
6309                "strategy": "retrieval_window",
6310                "retrieval_window": {
6311                    "target_usage_ratio": 0.8,
6312                    "trigger_usage_ratio": 0.8
6313                }
6314            }),
6315            serde_json::json!({
6316                "strategy": "retrieval_window",
6317                "retrieval_window": {
6318                    "target_usage_ratio": 0.005,
6319                    "trigger_usage_ratio": 0.006
6320                }
6321            }),
6322            serde_json::json!({
6323                "strategy": "retrieval_window",
6324                "retrieval_window": {"history_tool_required": false}
6325            }),
6326        ] {
6327            serde_json::from_value::<ContextManagementConfig>(invalid)
6328                .expect_err("unsafe retrieval-window policy must fail closed");
6329        }
6330    }
6331
6332    #[test]
6333    fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6334        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6335            "provider_type": "openai",
6336            "enabled": true
6337        }))
6338        .unwrap();
6339        let mut config = Config::default();
6340        config.values.provider = "gemini".to_string();
6341        config
6342            .provider_instances
6343            .insert("work".to_string(), instance);
6344        config.default_provider_instance = Some("work".to_string());
6345        config.providers_mut().openai = Some(OpenAIConfig::default());
6346
6347        let json = serde_json::to_value(&config).unwrap();
6348        assert_eq!(json["provider"], "gemini");
6349        assert!(json["providers"]["openai"].is_object());
6350        assert_eq!(json["default_provider_instance"], "work");
6351
6352        let round_trip: Config = serde_json::from_value(json).unwrap();
6353        assert_eq!(round_trip.provider, "gemini");
6354        assert_eq!(
6355            round_trip.default_provider_instance.as_deref(),
6356            Some("work")
6357        );
6358        assert!(round_trip.provider_instances.contains_key("work"));
6359        assert!(round_trip.providers().openai.is_some());
6360    }
6361
6362    #[test]
6363    fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6364        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6365        let dir = tempfile::tempdir().unwrap();
6366        let mut config = Config::default();
6367        config.values.provider = "anthropic".to_string();
6368        config.provider_instances.insert(
6369            "work".to_string(),
6370            serde_json::from_value(serde_json::json!({
6371                "provider_type": "openai",
6372                "model": "gpt-instance",
6373                "enabled": true,
6374                "future_instance_metadata": "preserved"
6375            }))
6376            .unwrap(),
6377        );
6378        config.default_provider_instance = Some("work".to_string());
6379        config.features.provider_model_ref = true;
6380        config.providers_mut().openai = Some(OpenAIConfig::default());
6381        config.providers_mut().anthropic = Some(AnthropicConfig::default());
6382        config
6383            .providers_mut()
6384            .extra
6385            .insert("provider".to_string(), serde_json::json!("anthropic"));
6386        config.providers_mut().extra.insert(
6387            "future_provider".to_string(),
6388            serde_json::json!({"kept": true}),
6389        );
6390
6391        let (root_bytes, provider_bytes) =
6392            config.prepare_provider_transaction_documents(&[]).unwrap();
6393        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6394        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6395        assert!(root.get("provider").is_none());
6396        assert!(root.get("providers").is_none());
6397        assert_eq!(root["default_provider_instance"], "work");
6398        assert_eq!(
6399            root["provider_instances"]["work"]["future_instance_metadata"],
6400            "preserved"
6401        );
6402        assert!(providers.get("provider").is_none());
6403        for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6404            assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6405        }
6406        assert_eq!(providers["future_provider"]["kept"], true);
6407
6408        config.save_to_dir(dir.path().to_path_buf()).unwrap();
6409        let saved_root: Value =
6410            serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6411                .unwrap();
6412        let saved_providers: Value =
6413            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6414                .unwrap();
6415        assert!(saved_root.get("provider").is_none());
6416        assert!(saved_root.get("providers").is_none());
6417        assert_eq!(saved_root["default_provider_instance"], "work");
6418        assert!(saved_providers.get("openai").is_none());
6419        assert!(saved_providers.get("anthropic").is_none());
6420        assert!(saved_providers.get("provider").is_none());
6421        assert_eq!(saved_providers["future_provider"]["kept"], true);
6422
6423        config.providers_mut().gemini = Some(GeminiConfig::default());
6424        config.save_providers_to_dir(dir.path()).unwrap();
6425        let provider_only: Value =
6426            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6427                .unwrap();
6428        assert!(provider_only.get("gemini").is_none());
6429        assert!(provider_only.get("provider").is_none());
6430        assert_eq!(provider_only["future_provider"]["kept"], true);
6431    }
6432
6433    #[test]
6434    fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6435        let mut config = Config::default();
6436        config.values.provider = "openai".to_string();
6437        config.providers_mut().openai = Some(OpenAIConfig {
6438            model: Some("gpt-legacy".to_string()),
6439            ..OpenAIConfig::default()
6440        });
6441        config.provider_instances.insert(
6442            "work".to_string(),
6443            serde_json::from_value(serde_json::json!({
6444                "provider_type": "copilot",
6445                "enabled": true
6446            }))
6447            .unwrap(),
6448        );
6449        config.default_provider_instance = Some("openai".to_string());
6450
6451        let (root_bytes, provider_bytes) =
6452            config.prepare_provider_transaction_documents(&[]).unwrap();
6453        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6454        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6455        assert_eq!(root["provider"], "openai");
6456        assert_eq!(root["default_provider_instance"], "openai");
6457        assert!(root["provider_instances"]["work"].is_object());
6458        assert_eq!(providers["openai"]["model"], "gpt-legacy");
6459    }
6460
6461    #[test]
6462    fn persistence_keeps_legacy_provider_without_instance_default() {
6463        let values = ConfigValues {
6464            provider: "gemini".to_string(),
6465            ..ConfigValues::default()
6466        };
6467
6468        let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6469        assert_eq!(json["provider"], "gemini");
6470    }
6471
6472    #[test]
6473    fn stream_timeout_rejects_zero_and_unbounded_values() {
6474        for (field, invalid) in [
6475            ("transport_idle_timeout_secs", 0),
6476            ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6477            ("semantic_idle_timeout_secs", 0),
6478        ] {
6479            let mut timeout = serde_json::json!({});
6480            timeout[field] = serde_json::json!(invalid);
6481            let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6482                .expect_err("invalid timeout must be rejected");
6483            assert!(error.to_string().contains("stream timeout must be between"));
6484        }
6485    }
6486
6487    struct EnvVarGuard {
6488        key: &'static str,
6489        previous: Option<OsString>,
6490    }
6491
6492    impl EnvVarGuard {
6493        fn set(key: &'static str, value: &str) -> Self {
6494            let previous = std::env::var_os(key);
6495            std::env::set_var(key, value);
6496            Self { key, previous }
6497        }
6498
6499        fn unset(key: &'static str) -> Self {
6500            let previous = std::env::var_os(key);
6501            std::env::remove_var(key);
6502            Self { key, previous }
6503        }
6504    }
6505
6506    impl Drop for EnvVarGuard {
6507        fn drop(&mut self) {
6508            match &self.previous {
6509                Some(value) => std::env::set_var(self.key, value),
6510                None => std::env::remove_var(self.key),
6511            }
6512        }
6513    }
6514
6515    #[test]
6516    fn run_budget_config_merge_is_tighten_only_per_field() {
6517        let config_default = RunBudgetConfig {
6518            max_total_tokens: Some(100_000),
6519            max_tool_calls: Some(500),
6520            max_subagents: Some(10),
6521            max_rounds: Some(200),
6522        };
6523
6524        // No override at all: config default passes through unchanged.
6525        assert_eq!(
6526            config_default.merged_with_override(None),
6527            config_default,
6528            "no override falls back to the config default entirely"
6529        );
6530
6531        // Override TIGHTENS exactly one field; the others keep the config
6532        // default (per-field, not all-or-nothing).
6533        let tighten_one = RunBudgetConfig {
6534            max_total_tokens: Some(5_000),
6535            max_tool_calls: None,
6536            max_subagents: None,
6537            max_rounds: None,
6538        };
6539        let merged = config_default.merged_with_override(Some(&tighten_one));
6540        assert_eq!(merged.max_total_tokens, Some(5_000));
6541        assert_eq!(merged.max_tool_calls, Some(500));
6542        assert_eq!(merged.max_subagents, Some(10));
6543        assert_eq!(merged.max_rounds, Some(200));
6544
6545        // A LOOSER override is clamped to the config default: a client can
6546        // never raise the operator's ceiling (PR #539 review, finding #3).
6547        let loosen_attempt = RunBudgetConfig {
6548            max_total_tokens: Some(999_999_999),
6549            max_tool_calls: Some(10_000),
6550            max_subagents: Some(1_000),
6551            max_rounds: Some(100_000),
6552        };
6553        assert_eq!(
6554            config_default.merged_with_override(Some(&loosen_attempt)),
6555            config_default,
6556            "looser per-request values must be clamped to the config ceiling"
6557        );
6558
6559        // Nor can it REMOVE a configured ceiling by omitting the field: an
6560        // absent override field keeps the config default, it does not mean
6561        // unlimited.
6562        let empty_override = RunBudgetConfig::default();
6563        assert_eq!(
6564            config_default.merged_with_override(Some(&empty_override)),
6565            config_default,
6566            "an all-absent override body keeps every configured ceiling"
6567        );
6568
6569        // An unlimited config default CAN be tightened by the request (the
6570        // request is the only ceiling then), and stays unlimited on fields the
6571        // request does not set.
6572        let unlimited_default = RunBudgetConfig::default();
6573        let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6574        assert_eq!(merged.max_total_tokens, Some(5_000));
6575        assert_eq!(merged.max_tool_calls, None);
6576        assert_eq!(merged.max_subagents, None);
6577        assert_eq!(merged.max_rounds, None);
6578
6579        // The round cap tightens the same way: a request can cap rounds below
6580        // an unlimited config default but never raise a configured ceiling.
6581        let tighten_rounds = RunBudgetConfig {
6582            max_total_tokens: None,
6583            max_tool_calls: None,
6584            max_subagents: None,
6585            max_rounds: Some(20),
6586        };
6587        let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6588        assert_eq!(merged.max_rounds, Some(20));
6589        let merged = config_default.merged_with_override(Some(&tighten_rounds));
6590        assert_eq!(
6591            merged.max_rounds,
6592            Some(20),
6593            "a stricter per-request round cap wins over the config default"
6594        );
6595    }
6596
6597    #[test]
6598    fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6599        assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6600        assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6601        assert_eq!(RunBudgetConfig::default().max_subagents, None);
6602        assert_eq!(RunBudgetConfig::default().max_rounds, None);
6603
6604        let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6605        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6606        assert_eq!(cfg.max_total_tokens, Some(250_000));
6607        assert_eq!(
6608            cfg.max_tool_calls, None,
6609            "absent field defaults to unlimited"
6610        );
6611        assert_eq!(
6612            cfg.max_rounds, None,
6613            "absent round cap defaults to unlimited"
6614        );
6615
6616        assert_eq!(cfg.max_subagents, Some(3));
6617
6618        // The round cap round-trips like the other fields.
6619        let json = r#"{ "max_rounds": 250 }"#;
6620        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6621        assert_eq!(cfg.max_rounds, Some(250));
6622        let round_tripped: RunBudgetConfig =
6623            serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6624        assert_eq!(round_tripped.max_rounds, Some(250));
6625        assert_eq!(round_tripped.max_total_tokens, None);
6626
6627        // Absent fields are omitted on serialize (skip_serializing_if), so an
6628        // all-default config round-trips to `{}` rather than four explicit
6629        // nulls.
6630        let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6631        assert_eq!(empty, "{}");
6632    }
6633
6634    #[test]
6635    fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6636        let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6637        assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6638        assert!(!serde_json::to_string(&old)
6639            .unwrap()
6640            .contains("max_spawn_depth"));
6641        for depth in [0, 1, 8] {
6642            let config: SubagentsConfig =
6643                serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6644            assert_eq!(config.effective_max_spawn_depth(), depth);
6645            assert_eq!(
6646                serde_json::to_value(config).unwrap()["max_spawn_depth"],
6647                depth
6648            );
6649        }
6650        for invalid in [
6651            serde_json::json!(-1),
6652            serde_json::json!(1.5),
6653            serde_json::json!("4"),
6654        ] {
6655            assert!(serde_json::from_value::<SubagentsConfig>(
6656                serde_json::json!({"max_spawn_depth": invalid}),
6657            )
6658            .is_err());
6659        }
6660    }
6661
6662    #[test]
6663    fn subagents_config_without_remote_placements_deserializes_empty() {
6664        // An OLD config (predating P1.5) has no `remote_placements` key — it must
6665        // still deserialize, with an empty placement list (default = local path).
6666        let json = r#"{ "max_concurrent": 4 }"#;
6667        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6668        assert_eq!(cfg.max_concurrent, Some(4));
6669        assert!(cfg.remote_placements.is_empty());
6670        // And an empty placement list is omitted on re-serialize (skip_if empty).
6671        let back = serde_json::to_string(&cfg).unwrap();
6672        assert!(
6673            !back.contains("remote_placements"),
6674            "empty vec is skipped: {back}"
6675        );
6676    }
6677
6678    #[test]
6679    fn remote_actor_placement_round_trips() {
6680        let json = r#"{
6681            "remote_placements": [
6682                {
6683                    "role": "explorer",
6684                    "endpoint": "wss://gpu-host:8443",
6685                    "token_env": "WORKER_TOKEN",
6686                    "ca_cert_file": "/etc/bamboo/worker.pem"
6687                },
6688                { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6689            ]
6690        }"#;
6691        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6692        assert_eq!(cfg.remote_placements.len(), 2);
6693        let p0 = &cfg.remote_placements[0];
6694        assert_eq!(p0.role, "explorer");
6695        assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6696        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6697        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6698        // Optional fields default to None and are skipped on serialize.
6699        let p1 = &cfg.remote_placements[1];
6700        assert_eq!(p1.role, "writer");
6701        assert!(p1.token_env.is_none());
6702        assert!(p1.ca_cert_file.is_none());
6703
6704        let back = serde_json::to_string(&cfg).unwrap();
6705        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6706        assert_eq!(cfg, reparsed, "round-trip is stable");
6707        assert!(!back.contains("\"token_env\":null"));
6708        assert!(!back.contains("\"ca_cert_file\":null"));
6709    }
6710
6711    #[test]
6712    fn subagents_config_without_schedulable_placements_deserializes_empty() {
6713        // An OLD config (predating P2b) has no `schedulable_placements` key — it
6714        // must still deserialize, with an empty list (default = local path).
6715        let json = r#"{ "max_concurrent": 4 }"#;
6716        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6717        assert!(cfg.schedulable_placements.is_empty());
6718        // An empty list is omitted on re-serialize (skip_if empty).
6719        let back = serde_json::to_string(&cfg).unwrap();
6720        assert!(
6721            !back.contains("schedulable_placements"),
6722            "empty vec is skipped: {back}"
6723        );
6724    }
6725
6726    #[test]
6727    fn schedulable_placement_round_trips() {
6728        let json = r#"{
6729            "schedulable_placements": [
6730                {
6731                    "role": "explorer",
6732                    "pool": "gpu-pool",
6733                    "registry_url": "https://control-plane:9562",
6734                    "token_env": "WORKER_TOKEN",
6735                    "ca_cert_file": "/etc/bamboo/worker.pem"
6736                },
6737                { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6738            ]
6739        }"#;
6740        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6741        assert_eq!(cfg.schedulable_placements.len(), 2);
6742        let p0 = &cfg.schedulable_placements[0];
6743        assert_eq!(p0.role, "explorer");
6744        assert_eq!(p0.pool, "gpu-pool");
6745        assert_eq!(p0.registry_url, "https://control-plane:9562");
6746        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6747        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6748        // Optional fields default to None and are skipped on serialize.
6749        let p1 = &cfg.schedulable_placements[1];
6750        assert_eq!(p1.role, "writer");
6751        assert_eq!(p1.pool, "cpu-pool");
6752        assert!(p1.token_env.is_none());
6753        assert!(p1.ca_cert_file.is_none());
6754
6755        let back = serde_json::to_string(&cfg).unwrap();
6756        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6757        assert_eq!(cfg, reparsed, "round-trip is stable");
6758        assert!(!back.contains("\"token_env\":null"));
6759        assert!(!back.contains("\"ca_cert_file\":null"));
6760    }
6761
6762    #[test]
6763    fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6764        // An OLD config (predating #54's wiring) has no `mcp_role_allowlist`
6765        // key — it must still deserialize, with an empty list (default =
6766        // every role unrestricted, identical to pre-#54 behavior).
6767        let json = r#"{ "max_concurrent": 4 }"#;
6768        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6769        assert!(cfg.mcp_role_allowlist.is_empty());
6770        // An empty list is omitted on re-serialize (skip_if empty).
6771        let back = serde_json::to_string(&cfg).unwrap();
6772        assert!(
6773            !back.contains("mcp_role_allowlist"),
6774            "empty vec is skipped: {back}"
6775        );
6776    }
6777
6778    #[test]
6779    fn mcp_role_allowlist_entry_round_trips() {
6780        let json = r#"{
6781            "mcp_role_allowlist": [
6782                { "role": "researcher", "tools": ["fetch_url"] },
6783                { "role": "sandboxed", "tools": [] }
6784            ]
6785        }"#;
6786        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6787        assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6788        assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6789        assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6790        // An empty `tools` list is an explicit lockout, distinct from the role
6791        // being absent — it must round-trip as an empty (not omitted) list.
6792        assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6793        assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6794
6795        let back = serde_json::to_string(&cfg).unwrap();
6796        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6797        assert_eq!(cfg, reparsed, "round-trip is stable");
6798    }
6799
6800    #[test]
6801    fn server_config_without_tls_field_deserializes_back_compat() {
6802        // An old config.json `server` section with no `tls` key must still
6803        // deserialize, leaving `tls` as None (zero behavior change on upgrade).
6804        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6805            "port": 9562,
6806            "bind": "127.0.0.1"
6807        }))
6808        .expect("legacy server config without tls should deserialize");
6809
6810        assert_eq!(server.tls, None);
6811        assert_eq!(server.port, 9562);
6812        assert_eq!(server.bind, "127.0.0.1");
6813    }
6814
6815    #[test]
6816    fn server_config_omits_tls_when_none() {
6817        // `skip_serializing_if = "Option::is_none"` keeps the on-disk shape
6818        // identical to before for the common (no-TLS) case.
6819        let server = ServerConfig::default();
6820        let value = serde_json::to_value(&server).expect("server config should serialize");
6821        let obj = value
6822            .as_object()
6823            .expect("server config serializes to object");
6824        assert!(
6825            !obj.contains_key("tls"),
6826            "tls must be omitted when None, got: {value}"
6827        );
6828    }
6829
6830    #[test]
6831    fn server_config_with_tls_roundtrips() {
6832        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6833            "port": 9562,
6834            "bind": "0.0.0.0",
6835            "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6836        }))
6837        .expect("server config with tls should deserialize");
6838
6839        let tls = server.tls.clone().expect("tls should be Some");
6840        assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6841        assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6842
6843        // Round-trips: tls survives a serialize → deserialize cycle.
6844        let value = serde_json::to_value(&server).expect("serialize");
6845        assert!(value.as_object().unwrap().contains_key("tls"));
6846        let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6847        assert_eq!(back.tls, server.tls);
6848    }
6849
6850    #[test]
6851    fn access_control_without_devices_field_deserializes_back_compat() {
6852        // An old config.json `access_control` with no `devices` key must still
6853        // deserialize, leaving `devices` empty (root-password-only mode).
6854        let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6855            "password_enabled": true,
6856            "password_hash": "deadbeef",
6857            "password_salt": "01020304",
6858        }))
6859        .expect("legacy access_control without devices should deserialize");
6860
6861        assert!(access.devices.is_empty());
6862        assert!(access.password_enabled);
6863    }
6864
6865    #[test]
6866    fn access_control_omits_devices_when_empty() {
6867        // `skip_serializing_if = "Vec::is_empty"` keeps the on-disk shape
6868        // identical for instances that never paired a device.
6869        let access = AccessControlConfig {
6870            password_enabled: true,
6871            repair_required: false,
6872            password_hash: Some("deadbeef".to_string()),
6873            password_salt: Some("01020304".to_string()),
6874            password_credential_ref: None,
6875            password_configured: false,
6876            updated_at: None,
6877            devices: Vec::new(),
6878        };
6879        let value = serde_json::to_value(&access).expect("serialize");
6880        let obj = value.as_object().expect("object");
6881        assert!(
6882            !obj.contains_key("devices"),
6883            "devices must be omitted when empty, got: {value}"
6884        );
6885    }
6886
6887    #[test]
6888    fn access_control_with_devices_roundtrips() {
6889        let device = DeviceCredential {
6890            device_id: "bamboo_0123456789ab".to_string(),
6891            label: "iPhone 15".to_string(),
6892            token_hash: "abcd".to_string(),
6893            token_salt: "ef01".to_string(),
6894            token_credential_ref: None,
6895            token_configured: false,
6896            created_at: "2026-06-23T00:00:00Z".to_string(),
6897            last_used_at: None,
6898            revoked: false,
6899        };
6900        let access = AccessControlConfig {
6901            password_enabled: true,
6902            repair_required: false,
6903            password_hash: Some("deadbeef".to_string()),
6904            password_salt: Some("01020304".to_string()),
6905            password_credential_ref: None,
6906            password_configured: false,
6907            updated_at: None,
6908            devices: vec![device.clone()],
6909        };
6910        let value = serde_json::to_value(&access).expect("serialize");
6911        assert!(value.as_object().unwrap().contains_key("devices"));
6912        assert!(value["devices"][0].get("token_hash").is_none());
6913        assert!(value["devices"][0].get("token_salt").is_none());
6914        let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6915        assert_eq!(back.devices[0].device_id, device.device_id);
6916        assert!(back.devices[0].token_hash.is_empty());
6917        assert!(back.devices[0].token_salt.is_empty());
6918    }
6919
6920    #[test]
6921    fn reasoning_effort_for_key_resolves_instance_id() {
6922        // Multi-instance mode: the routing key is an instance id and the effort
6923        // lives under provider_instances[<id>] — previously this fell through to
6924        // None because the resolver only matched literal provider types.
6925        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6926            "provider_type": "copilot",
6927            "reasoning_effort": "high",
6928        }))
6929        .expect("instance config should deserialize");
6930
6931        let mut config = Config::create_default();
6932        config
6933            .provider_instances
6934            .insert("copilot-work".to_string(), instance);
6935
6936        assert_eq!(
6937            config.reasoning_effort_for_key("copilot-work"),
6938            Some(ReasoningEffort::High),
6939        );
6940    }
6941
6942    #[test]
6943    fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6944        // Legacy mode: the `bodhi` provider previously had no match arm.
6945        let mut config = Config::create_default();
6946        config.providers.bodhi = Some(
6947            serde_json::from_value(serde_json::json!({
6948                "reasoning_effort": "xhigh",
6949            }))
6950            .expect("bodhi config should deserialize"),
6951        );
6952
6953        assert_eq!(
6954            config.reasoning_effort_for_key("bodhi"),
6955            Some(ReasoningEffort::Xhigh),
6956        );
6957    }
6958
6959    #[test]
6960    fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6961        let mut config = Config::create_default();
6962        config.providers.openai = Some(
6963            serde_json::from_value(serde_json::json!({
6964                "api_key": "sk-test",
6965                "reasoning_effort": "low",
6966            }))
6967            .expect("openai config should deserialize"),
6968        );
6969
6970        assert_eq!(
6971            config.reasoning_effort_for_key("openai"),
6972            Some(ReasoningEffort::Low),
6973        );
6974    }
6975
6976    #[test]
6977    fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6978        let config = Config::create_default();
6979        assert_eq!(config.reasoning_effort_for_key("nope"), None);
6980        assert_eq!(config.reasoning_effort_for_key("   "), None);
6981    }
6982
6983    struct TempHome {
6984        path: PathBuf,
6985    }
6986
6987    impl TempHome {
6988        fn new() -> Self {
6989            // `pid + nanos` alone is NOT collision-free (issue #486): every
6990            // test in this binary shares the pid, and two tests started
6991            // concurrently by the multi-threaded harness can observe the
6992            // same `SystemTime` nanos tick. Two `TempHome`s colliding on one
6993            // path means they share a directory — and the first test's
6994            // `Drop` (`remove_dir_all`) then yanks the directory out from
6995            // under the other test's in-flight `save_to_dir`, whose
6996            // tmp-file+rename dance fails with ENOENT ("Failed to write
6997            // config file ... os error 2" — `save_rotates_backup_generations`'s
6998            // exact one-off CI failure mode). A per-process atomic counter
6999            // in the name makes each instance unique unconditionally.
7000            static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
7001                std::sync::atomic::AtomicU64::new(0);
7002            let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7003            let nanos = SystemTime::now()
7004                .duration_since(UNIX_EPOCH)
7005                .expect("clock should be after unix epoch")
7006                .as_nanos();
7007            let path = std::env::temp_dir().join(format!(
7008                "chat-core-config-test-{}-{}-{}",
7009                std::process::id(),
7010                nanos,
7011                unique
7012            ));
7013            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
7014            Self { path }
7015        }
7016
7017        fn set_config_json(&self, content: &str) {
7018            // Treat `path` as the Bamboo data dir and write `config.json` into it.
7019            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
7020            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
7021            std::fs::write(self.path.join("config.json"), content)
7022                .expect("failed to write config.json");
7023        }
7024    }
7025
7026    impl Drop for TempHome {
7027        fn drop(&mut self) {
7028            let _ = std::fs::remove_dir_all(&self.path);
7029        }
7030    }
7031
7032    // Delegate to the single crate-wide test lock so env-mutating tests across
7033    // `config`, `encryption`, and `paths` serialize against one another (they
7034    // all mutate the same process-global env / static caches).
7035    fn env_lock() -> &'static Mutex<()> {
7036        crate::test_support::env_cache_lock()
7037    }
7038
7039    /// Acquire the environment lock, recovering from poison if a previous test failed
7040    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
7041        env_lock().lock().unwrap_or_else(|poisoned| {
7042            // Lock was poisoned by a previous test failure - recover it
7043            poisoned.into_inner()
7044        })
7045    }
7046
7047    #[test]
7048    fn parse_bool_env_true_values() {
7049        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
7050            assert!(parse_bool_env(value), "value {value:?} should be true");
7051        }
7052    }
7053
7054    #[test]
7055    fn parse_bool_env_false_values() {
7056        for value in ["0", "false", "no", "off", "", "  "] {
7057            assert!(!parse_bool_env(value), "value {value:?} should be false");
7058        }
7059    }
7060
7061    #[test]
7062    fn config_new_ignores_http_proxy_env_vars() {
7063        let _lock = env_lock_acquire();
7064        let temp_home = TempHome::new();
7065        temp_home.set_config_json(
7066            r#"{
7067  "http_proxy": "",
7068  "https_proxy": ""
7069}"#,
7070        );
7071
7072        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
7073        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
7074
7075        let config = Config::from_data_dir(Some(temp_home.path.clone()));
7076
7077        assert!(
7078            config.http_proxy.is_empty(),
7079            "config should ignore HTTP_PROXY env var"
7080        );
7081        assert!(
7082            config.https_proxy.is_empty(),
7083            "config should ignore HTTPS_PROXY env var"
7084        );
7085    }
7086
7087    #[test]
7088    fn config_new_loads_config_when_proxy_fields_omitted() {
7089        let _lock = env_lock_acquire();
7090        let temp_home = TempHome::new();
7091        temp_home.set_config_json(
7092            r#"{
7093  "provider": "openai",
7094  "providers": {
7095    "openai": {
7096      "api_key": "sk-test",
7097      "model": "gpt-4o"
7098    }
7099  }
7100}"#,
7101        );
7102
7103        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
7104        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
7105
7106        let config = Config::from_data_dir(Some(temp_home.path.clone()));
7107
7108        assert_eq!(
7109            config
7110                .providers
7111                .openai
7112                .as_ref()
7113                .and_then(|c| c.model.as_deref()),
7114            Some("gpt-4o"),
7115            "config should load provider model from config file even when proxy fields are omitted"
7116        );
7117        assert!(config.http_proxy.is_empty());
7118        assert!(config.https_proxy.is_empty());
7119    }
7120
7121    #[test]
7122    fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
7123        let _lock = crate::test_support::env_cache_lock_acquire();
7124        let mut config = Config::default();
7125        config.env_vars.extend([
7126            EnvVarEntry {
7127                name: "SECRET_TOKEN".to_string(),
7128                value: "top-secret".to_string(),
7129                secret: true,
7130                value_encrypted: None,
7131                credential_ref: None,
7132                configured: true,
7133                description: Some("Service token".to_string()),
7134            },
7135            EnvVarEntry {
7136                name: "API_BASE".to_string(),
7137                value: "https://internal.example".to_string(),
7138                secret: false,
7139                value_encrypted: None,
7140                credential_ref: None,
7141                configured: true,
7142                description: Some("Internal API base".to_string()),
7143            },
7144        ]);
7145
7146        config.publish_env_vars();
7147
7148        let injected = Config::current_env_vars();
7149        assert_eq!(
7150            injected.get("SECRET_TOKEN").map(String::as_str),
7151            Some("top-secret")
7152        );
7153        assert_eq!(
7154            injected.get("API_BASE").map(String::as_str),
7155            Some("https://internal.example")
7156        );
7157
7158        let prompt_safe = Config::current_prompt_safe_env_vars();
7159        assert_eq!(prompt_safe.len(), 2);
7160        assert!(prompt_safe.iter().any(|entry| {
7161            entry.name == "SECRET_TOKEN"
7162                && entry.secret
7163                && entry.description.as_deref() == Some("Service token")
7164        }));
7165        assert!(prompt_safe.iter().any(|entry| {
7166            entry.name == "API_BASE"
7167                && !entry.secret
7168                && entry.description.as_deref() == Some("Internal API base")
7169        }));
7170        assert!(!prompt_safe
7171            .iter()
7172            .any(|entry| entry.name.contains("top-secret")));
7173        assert!(!prompt_safe.iter().any(|entry| {
7174            entry
7175                .description
7176                .as_deref()
7177                .is_some_and(|value| value.contains("https://internal.example"))
7178        }));
7179    }
7180
7181    #[test]
7182    fn from_data_dir_without_publish_does_not_clobber_global_cache() {
7183        let _lock = crate::test_support::env_cache_lock_acquire();
7184
7185        // Seed the global cache with a marker "owned" by the live config.
7186        let mut live = Config::default();
7187        live.env_vars.extend([EnvVarEntry {
7188            name: "BAMBOO_CACHE_OWNER_40".to_string(),
7189            value: "live".to_string(),
7190            secret: false,
7191            value_encrypted: None,
7192            credential_ref: None,
7193            configured: true,
7194            description: None,
7195        }]);
7196        live.publish_env_vars();
7197        assert_eq!(
7198            Config::current_env_vars()
7199                .get("BAMBOO_CACHE_OWNER_40")
7200                .map(String::as_str),
7201            Some("live")
7202        );
7203
7204        // A config.json on disk sets the SAME var to a different (stale) value.
7205        let temp = TempHome::new();
7206        temp.set_config_json(
7207            &serde_json::json!({
7208                "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
7209            })
7210            .to_string(),
7211        );
7212
7213        // Non-publishing load reads the disk value into the returned Config but
7214        // must NOT touch the global cache.
7215        let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7216        assert_eq!(
7217            loaded
7218                .env_vars
7219                .iter()
7220                .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
7221                .map(|e| e.value.as_str()),
7222            Some("stale-disk"),
7223            "the returned Config holds the disk value"
7224        );
7225        assert_eq!(
7226            Config::current_env_vars()
7227                .get("BAMBOO_CACHE_OWNER_40")
7228                .map(String::as_str),
7229            Some("live"),
7230            "but the global cache is UNTOUCHED — no clobber (#40)"
7231        );
7232
7233        // Contrast: the publishing variant DOES clobber the cache.
7234        let _ = Config::from_data_dir(Some(temp.path.clone()));
7235        assert_eq!(
7236            Config::current_env_vars()
7237                .get("BAMBOO_CACHE_OWNER_40")
7238                .map(String::as_str),
7239            Some("stale-disk"),
7240            "the publishing loader clobbers the cache (contrast)"
7241        );
7242    }
7243
7244    fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
7245        std::fs::read_dir(dir)
7246            .unwrap()
7247            .filter_map(|e| e.ok())
7248            .any(|e| {
7249                e.file_name()
7250                    .to_string_lossy()
7251                    .contains("config.json.corrupted.")
7252            })
7253    }
7254
7255    #[test]
7256    fn corrupt_config_recovered_from_backup_and_quarantined() {
7257        let temp = TempHome::new();
7258        // Last-known-good backup with a distinctive value.
7259        std::fs::write(
7260            temp.path.join("config.json.bak"),
7261            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7262        )
7263        .unwrap();
7264        // Corrupt primary config.json.
7265        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7266
7267        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7268        assert_eq!(
7269            config.http_proxy, "http://from-backup",
7270            "recovered from config.json.bak instead of losing all config"
7271        );
7272        assert!(
7273            dir_has_quarantine_file(&temp.path),
7274            "corrupt config.json was quarantined (preserved), not discarded"
7275        );
7276    }
7277
7278    #[test]
7279    fn corrupt_config_without_backup_quarantines_then_defaults() {
7280        let temp = TempHome::new();
7281        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7282
7283        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7284        assert!(
7285            config.http_proxy.is_empty(),
7286            "no backup -> falls back to defaults"
7287        );
7288        assert!(
7289            dir_has_quarantine_file(&temp.path),
7290            "corrupt config.json is quarantined even when there's no backup"
7291        );
7292    }
7293
7294    #[test]
7295    fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
7296        let temp = TempHome::new();
7297        // A valid JSON OBJECT, but `env_vars` is the wrong type (string, not array)
7298        // so STRICT parse fails. There is NO config.json.bak, so recovery must come
7299        // from field-level salvage: `http_proxy` is valid and must survive; the bad
7300        // `env_vars` resets to its default.
7301        temp.set_config_json(
7302            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7303        );
7304
7305        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7306        assert_eq!(
7307            config.http_proxy, "http://salvaged",
7308            "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
7309        );
7310        assert!(
7311            config.env_vars.is_empty(),
7312            "the corrupt field reset to its default instead of failing the whole load"
7313        );
7314        assert!(
7315            dir_has_quarantine_file(&temp.path),
7316            "the corrupt config.json was still quarantined for inspection"
7317        );
7318    }
7319
7320    #[test]
7321    fn salvage_preferred_over_backup_for_most_recent_intent() {
7322        let temp = TempHome::new();
7323        // An OLDER last-known-good backup...
7324        std::fs::write(
7325            temp.path.join("config.json.bak"),
7326            serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
7327        )
7328        .unwrap();
7329        // ...and a NEWER config that is corrupt but field-salvageable.
7330        temp.set_config_json(
7331            r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
7332        );
7333
7334        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7335        assert_eq!(
7336            config.http_proxy, "http://new-salvaged",
7337            "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7338        );
7339    }
7340
7341    #[test]
7342    fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7343        let temp = TempHome::new();
7344        // Backup carries TWO good values.
7345        std::fs::write(
7346            temp.path.join("config.json.bak"),
7347            serde_json::json!({
7348                "http_proxy": "http://old-from-backup",
7349                "https_proxy": "https://kept-from-backup",
7350            })
7351            .to_string(),
7352        )
7353        .unwrap();
7354        // The corrupt file updates http_proxy (newer), leaves https_proxy untouched,
7355        // and has one wrong-type field.
7356        temp.set_config_json(
7357            r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7358        );
7359
7360        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7361        // Best of both: the corrupt file's newer valid edit wins where it set one...
7362        assert_eq!(
7363            config.http_proxy, "http://newer-edit",
7364            "the corrupt file's newer valid edit is applied"
7365        );
7366        // ...and the backup's value survives for fields the corrupt file didn't fix.
7367        assert_eq!(
7368            config.https_proxy, "https://kept-from-backup",
7369            "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7370        );
7371    }
7372
7373    #[test]
7374    fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7375        let temp = TempHome::new();
7376        std::fs::write(
7377            temp.path.join("config.json.bak"),
7378            serde_json::json!({
7379                "providers": {
7380                    "anthropic": { "model": "claude-backup" }
7381                },
7382                "memory": {
7383                    "auto_dream_enabled": true
7384                },
7385                "subagents": {
7386                    "claude_code_model": "claude-code-backup"
7387                }
7388            })
7389            .to_string(),
7390        )
7391        .unwrap();
7392        assert!(!temp.path.join("providers.json").exists());
7393        assert!(!temp.path.join("memory.json").exists());
7394        assert!(!temp.path.join("subagents.json").exists());
7395
7396        let (salvaged, recovered_fields) = Config::salvage_partial(
7397            r#"{"providers":"schema-invalid-provider-section"}"#,
7398            &temp.path,
7399        )
7400        .expect("object-shaped corrupt config should be salvageable");
7401
7402        assert!(
7403            recovered_fields.is_empty(),
7404            "the schema-invalid provider field must not replace the backup baseline"
7405        );
7406        assert_eq!(
7407            salvaged
7408                .providers()
7409                .anthropic
7410                .as_ref()
7411                .and_then(|provider| provider.model.as_deref()),
7412            Some("claude-backup")
7413        );
7414        assert!(
7415            salvaged
7416                .memory()
7417                .as_ref()
7418                .expect("backup memory config survives")
7419                .auto_dream_enabled
7420        );
7421        assert_eq!(
7422            salvaged.subagents().claude_code_model.as_deref(),
7423            Some("claude-code-backup")
7424        );
7425    }
7426
7427    #[test]
7428    fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7429        let temp = TempHome::new();
7430        // Not even a JSON object -> nothing field-wise to salvage -> must fall
7431        // through to the .bak (the pre-#135 behavior is preserved).
7432        std::fs::write(
7433            temp.path.join("config.json.bak"),
7434            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7435        )
7436        .unwrap();
7437        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7438
7439        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7440        assert_eq!(
7441            config.http_proxy, "http://from-backup",
7442            "garbage (non-object) config skips salvage and recovers from .bak"
7443        );
7444    }
7445
7446    #[test]
7447    fn quarantine_files_are_capped_to_newest_n() {
7448        let temp = TempHome::new();
7449        let config_path = temp.path.join("config.json");
7450        std::fs::write(&config_path, "{}").unwrap();
7451
7452        // Drop more quarantines than the cap; each call sleeps so nanos (the name)
7453        // and mtime (the prune sort key) are distinct.
7454        for _ in 0..(QUARANTINE_KEEP + 3) {
7455            quarantine_corrupt_config(&config_path);
7456            std::thread::sleep(std::time::Duration::from_millis(3));
7457        }
7458
7459        let count = std::fs::read_dir(&temp.path)
7460            .unwrap()
7461            .filter_map(|e| e.ok())
7462            .filter(|e| {
7463                e.file_name()
7464                    .to_string_lossy()
7465                    .starts_with("config.json.corrupted.")
7466            })
7467            .count();
7468        assert_eq!(
7469            count, QUARANTINE_KEEP,
7470            "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7471        );
7472    }
7473
7474    #[test]
7475    fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7476        let temp = TempHome::new();
7477        // Primary AND the freshest .bak are corrupt; an older generation is good.
7478        std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7479        std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7480        std::fs::write(
7481            temp.path.join("config.json.bak.1"),
7482            serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7483        )
7484        .unwrap();
7485
7486        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7487        assert_eq!(
7488            config.http_proxy, "http://from-gen-1",
7489            "recovered from .bak.1 when both config.json and .bak are corrupt"
7490        );
7491    }
7492
7493    #[test]
7494    fn save_rotates_backup_generations() {
7495        let temp = TempHome::new();
7496        let path = temp.path.join("config.json");
7497        // v1 is the existing on-disk config.
7498        std::fs::write(
7499            &path,
7500            serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7501        )
7502        .unwrap();
7503
7504        let mut cfg = Config::create_default();
7505        // Save 1: backs up the existing v1 -> .bak, writes v2.
7506        cfg.http_proxy = "http://proxy-v2".to_string();
7507        cfg.save_to_dir(temp.path.clone()).unwrap();
7508        // Save 2: existing (v2) is parseable -> rotate .bak(v1) -> .bak.1, .bak = v2.
7509        cfg.http_proxy = "http://proxy-v3".to_string();
7510        cfg.save_to_dir(temp.path.clone()).unwrap();
7511
7512        let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7513        let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7514        assert!(
7515            bak.contains("proxy-v2"),
7516            ".bak holds the previous generation (v2)"
7517        );
7518        assert!(
7519            bak1.contains("proxy-v1"),
7520            ".bak.1 holds the older rotated generation (v1)"
7521        );
7522    }
7523
7524    #[test]
7525    fn save_backs_up_existing_config() {
7526        let temp = TempHome::new();
7527        // Existing (old) config on disk.
7528        std::fs::write(
7529            temp.path.join("config.json"),
7530            serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7531        )
7532        .unwrap();
7533
7534        let mut config = Config::create_default();
7535        config.http_proxy = "http://new".to_string();
7536        config
7537            .save_to_dir(temp.path.clone())
7538            .expect("save succeeds");
7539
7540        let backup =
7541            std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7542        assert!(
7543            backup.contains("http://old"),
7544            "config.json.bak holds the PREVIOUS config (last-known-good)"
7545        );
7546        let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7547        assert!(
7548            current.contains("http://new"),
7549            "config.json holds the new config"
7550        );
7551    }
7552
7553    #[test]
7554    fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7555        let temp = TempHome::new();
7556        // A good last-known-good backup...
7557        std::fs::write(
7558            temp.path.join("config.json.bak"),
7559            serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7560        )
7561        .unwrap();
7562        // ...but the on-disk config.json is corrupt (as it would be right after an
7563        // in-memory recovery, before any clean save).
7564        std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7565
7566        let mut config = Config::create_default();
7567        config.http_proxy = "http://new".to_string();
7568        config
7569            .save_to_dir(temp.path.clone())
7570            .expect("save succeeds");
7571
7572        // The good .bak must NOT have been clobbered by the corrupt config.json.
7573        let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7574        assert!(
7575            backup.contains("http://good-bak"),
7576            "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7577        );
7578    }
7579
7580    // ── config-corruption recovery confirmation gate (#153) ───────────────
7581
7582    #[test]
7583    fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7584        let temp = TempHome::new();
7585        std::fs::write(
7586            temp.path.join("config.json.bak"),
7587            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7588        )
7589        .unwrap();
7590        let corrupt_bytes = "{ not valid json ";
7591        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7592
7593        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7594        let status = config
7595            .recovery_status()
7596            .expect("a corrupt load must set a pending recovery status");
7597        assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7598        assert_eq!(
7599            status.source,
7600            ConfigRecoverySource::Backup { generation: 0 },
7601            "recovered from generation-0 (.bak)"
7602        );
7603        let quarantine_path = status
7604            .quarantine_path
7605            .as_ref()
7606            .expect("quarantine copy should have succeeded");
7607        assert_eq!(
7608            std::fs::read_to_string(quarantine_path).unwrap(),
7609            corrupt_bytes,
7610            "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7611        );
7612        assert_eq!(
7613            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7614            corrupt_bytes,
7615            "the original config.json itself is untouched by the load (only copied, not moved)"
7616        );
7617    }
7618
7619    #[test]
7620    fn recovery_status_set_from_salvage_lists_recovered_fields() {
7621        let temp = TempHome::new();
7622        temp.set_config_json(
7623            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7624        );
7625
7626        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7627        let status = config.recovery_status().expect("pending recovery");
7628        assert!(!status.confirmed);
7629        match &status.source {
7630            ConfigRecoverySource::Salvaged { fields } => {
7631                assert!(
7632                    fields.iter().any(|f| f == "http_proxy"),
7633                    "salvaged fields should list the recovered key: {fields:?}"
7634                );
7635            }
7636            other => panic!("expected Salvaged source, got {other:?}"),
7637        }
7638    }
7639
7640    #[test]
7641    fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7642        let temp = TempHome::new();
7643        // Not a JSON object at all -> salvage impossible; no .bak -> defaults.
7644        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7645
7646        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7647        let status = config.recovery_status().expect("pending recovery");
7648        assert!(!status.confirmed);
7649        assert_eq!(status.source, ConfigRecoverySource::Defaults);
7650    }
7651
7652    #[test]
7653    fn clean_load_never_sets_recovery_status() {
7654        let temp = TempHome::new();
7655        temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7656
7657        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7658        assert!(
7659            config.recovery_status().is_none(),
7660            "a config.json that parses cleanly must never carry a pending recovery status"
7661        );
7662    }
7663
7664    #[test]
7665    fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7666        let temp = TempHome::new();
7667        let corrupt_bytes = "}}} broken";
7668        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7669
7670        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7671        assert!(config.recovery_status().is_some());
7672
7673        let err = config
7674            .save_to_dir(temp.path.clone())
7675            .expect_err("save must refuse while recovery is unconfirmed");
7676        assert!(
7677            err.to_string().contains("recovered from corruption")
7678                || err.to_string().contains("confirm"),
7679            "error should explain the refused overwrite: {err}"
7680        );
7681
7682        // The corrupt original on disk must be BYTE FOR BYTE unchanged — the
7683        // refused save must not have touched it at all.
7684        assert_eq!(
7685            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7686            corrupt_bytes,
7687            "a refused save must leave the corrupt original untouched"
7688        );
7689    }
7690
7691    #[test]
7692    fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7693        let temp = TempHome::new();
7694        // Simulates a crash mid-write: valid JSON prefix, abruptly cut off.
7695        let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7696        std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7697
7698        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7699        let status = config.recovery_status().expect("pending recovery");
7700        let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7701        assert_eq!(
7702            std::fs::read_to_string(quarantine_path).unwrap(),
7703            truncated,
7704            "truncated original preserved byte for byte in quarantine"
7705        );
7706
7707        let err = config.save_to_dir(temp.path.clone());
7708        assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7709        assert_eq!(
7710            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7711            truncated,
7712            "the half-written original stays exactly as it was after a refused save"
7713        );
7714    }
7715
7716    #[test]
7717    fn confirm_recovery_allows_the_next_save() {
7718        let temp = TempHome::new();
7719        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7720
7721        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7722        assert!(config.recovery_status().is_some());
7723
7724        config.confirm_recovery();
7725        assert!(
7726            config.recovery_status().is_some_and(|s| s.confirmed),
7727            "confirm_recovery flips the flag but keeps the status around"
7728        );
7729
7730        config
7731            .save_to_dir(temp.path.clone())
7732            .expect("save must succeed once the recovery is confirmed");
7733    }
7734
7735    #[test]
7736    fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7737        let temp = TempHome::new();
7738        std::fs::write(
7739            temp.path.join("config.json"),
7740            r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7741        )
7742        .unwrap();
7743
7744        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7745        assert!(config.recovery_status().is_some());
7746        let quarantine_path = config
7747            .recovery_status()
7748            .unwrap()
7749            .quarantine_path
7750            .clone()
7751            .unwrap();
7752
7753        config
7754            .confirm_recovery_and_save_to_dir(temp.path.clone())
7755            .expect("confirm+save should succeed");
7756
7757        assert!(
7758            config.recovery_status().is_none(),
7759            "the pending flag is cleared once the recovery is confirmed and persisted"
7760        );
7761        let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7762        assert!(
7763            on_disk.contains("http://recovered"),
7764            "config.json now holds the recovered (salvaged) state"
7765        );
7766        // The quarantine copy of the original corrupt file must still exist,
7767        // untouched, even after the recovery is confirmed and persisted.
7768        assert!(
7769            quarantine_path.exists(),
7770            "the quarantined original survives confirmation — it's never deleted"
7771        );
7772    }
7773
7774    #[test]
7775    fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7776        let temp = TempHome::new();
7777        let mut config = Config::create_default();
7778        let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7779        assert!(
7780            err.is_err(),
7781            "confirming a recovery that was never pending must error, not silently succeed"
7782        );
7783    }
7784
7785    // ── connect.json split (#455) ────────────────────────────────────────
7786
7787    fn connect_platform_with_encrypted(
7788        platform_type: &str,
7789        token_encrypted: &str,
7790    ) -> ConnectPlatformConfig {
7791        ConnectPlatformConfig {
7792            id: None,
7793            project_id: None,
7794            platform_type: platform_type.to_string(),
7795            token: None,
7796            token_encrypted: Some(token_encrypted.to_string()),
7797            token_credential_ref: None,
7798            token_configured: false,
7799            app_id: None,
7800            app_secret: None,
7801            app_secret_encrypted: None,
7802            app_secret_credential_ref: None,
7803            app_secret_configured: false,
7804            domain: None,
7805            allow_from: vec!["user-1".to_string()],
7806            admin_from: Vec::new(),
7807        }
7808    }
7809
7810    fn connect_json_path(temp: &TempHome) -> PathBuf {
7811        temp.path.join("connect.json")
7812    }
7813
7814    #[test]
7815    fn save_splits_connect_into_sibling_connect_json() {
7816        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7817        let temp = TempHome::new();
7818
7819        let mut config = Config::create_default();
7820        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7821        config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7822
7823        config
7824            .save_to_dir(temp.path.clone())
7825            .expect("save succeeds");
7826
7827        let config_json: serde_json::Value =
7828            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7829                .unwrap();
7830        assert!(
7831            config_json.get("connect").is_none(),
7832            "config.json must not carry the `connect` key after a save"
7833        );
7834
7835        let connect_json: serde_json::Value =
7836            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7837                .unwrap();
7838        assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7839        assert!(
7840            connect_json["platforms"][0]["token_encrypted"]
7841                .as_str()
7842                .is_some_and(|v| !v.is_empty()),
7843            "the token is persisted in its encrypted form in connect.json"
7844        );
7845        assert!(
7846            connect_json["platforms"][0].get("token").is_none(),
7847            "the plaintext token is never persisted (skip_serializing)"
7848        );
7849    }
7850
7851    // ── stable connect.platforms id (#496) ───────────────────────────────
7852
7853    #[test]
7854    fn save_assigns_a_missing_connect_platform_id() {
7855        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7856        let temp = TempHome::new();
7857
7858        let mut config = Config::create_default();
7859        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7860        assert!(
7861            config.connect.platforms[0].id.is_none(),
7862            "precondition: the entry starts without an id"
7863        );
7864
7865        config
7866            .save_to_dir(temp.path.clone())
7867            .expect("save succeeds");
7868
7869        let connect_json: serde_json::Value =
7870            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7871                .unwrap();
7872        let persisted_id = connect_json["platforms"][0]["id"]
7873            .as_str()
7874            .expect("save_to_dir must backfill a missing id onto the persisted entry");
7875        assert!(!persisted_id.is_empty());
7876
7877        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7878        assert_eq!(
7879            reloaded.connect.platforms[0].id.as_deref(),
7880            Some(persisted_id),
7881            "the assigned id round-trips through a reload"
7882        );
7883    }
7884
7885    #[test]
7886    fn save_never_reassigns_an_existing_connect_platform_id() {
7887        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7888        let temp = TempHome::new();
7889
7890        let mut config = Config::create_default();
7891        let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7892        platform.id = Some("stable-id-123".to_string());
7893        config.connect.platforms = vec![platform];
7894
7895        config
7896            .save_to_dir(temp.path.clone())
7897            .expect("first save succeeds");
7898        // Save again (e.g. an unrelated settings change) — the id must not change.
7899        config
7900            .save_to_dir(temp.path.clone())
7901            .expect("second save succeeds");
7902
7903        let connect_json: serde_json::Value =
7904            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7905                .unwrap();
7906        assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7907    }
7908
7909    #[test]
7910    fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7911        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7912        let temp = TempHome::new();
7913
7914        let mut config = Config::create_default();
7915        config.connect.platforms = vec![
7916            connect_platform_with_encrypted("telegram", "cipher-a"),
7917            connect_platform_with_encrypted("telegram", "cipher-b"),
7918        ];
7919
7920        config
7921            .save_to_dir(temp.path.clone())
7922            .expect("save succeeds");
7923
7924        let connect_json: serde_json::Value =
7925            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7926                .unwrap();
7927        let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7928        let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7929        assert_ne!(
7930            id_a, id_b,
7931            "two entries sharing platform_type must still get distinct ids"
7932        );
7933    }
7934
7935    #[test]
7936    fn load_never_assigns_or_persists_an_id_by_itself() {
7937        let temp = TempHome::new();
7938        std::fs::write(
7939            connect_json_path(&temp),
7940            serde_json::json!({
7941                "platforms": [
7942                    { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7943                ]
7944            })
7945            .to_string(),
7946        )
7947        .unwrap();
7948        let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7949
7950        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7951
7952        assert!(
7953            config.connect.platforms[0].id.is_none(),
7954            "load alone must not backfill an id in memory"
7955        );
7956        let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7957        assert_eq!(
7958            connect_json_before, connect_json_after,
7959            "load must never rewrite connect.json on disk just to backfill an id (#493)"
7960        );
7961    }
7962
7963    #[test]
7964    fn load_merges_connect_json_into_config() {
7965        let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7966        let temp = TempHome::new();
7967        let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7968        std::fs::write(
7969            connect_json_path(&temp),
7970            serde_json::json!({
7971                "platforms": [
7972                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7973                ]
7974            })
7975            .to_string(),
7976        )
7977        .unwrap();
7978
7979        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7980        assert_eq!(config.connect.platforms.len(), 1);
7981        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7982        assert_eq!(
7983            config.connect.platforms[0].token.as_deref(),
7984            Some("connect-secret")
7985        );
7986        assert!(config.connect.platforms[0].token_encrypted.is_none());
7987    }
7988
7989    #[test]
7990    fn load_without_connect_json_yields_empty_inert_connect_config() {
7991        let temp = TempHome::new();
7992        temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7993
7994        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7995        assert!(
7996            config.connect.platforms.is_empty(),
7997            "no connect.json and no legacy key -> empty/inert connect config"
7998        );
7999        assert!(
8000            !connect_json_path(&temp).exists(),
8001            "load must not create connect.json when there is nothing to migrate"
8002        );
8003    }
8004
8005    #[test]
8006    fn migration_adopts_legacy_connect_key_and_writes_both_files() {
8007        let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
8008        let temp = TempHome::new();
8009        let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
8010        let legacy_cipher_for_assert = legacy_cipher.clone();
8011        // Legacy state (#453): connect lives inline in config.json, no connect.json yet.
8012        temp.set_config_json(
8013            &serde_json::json!({
8014                "http_proxy": "http://keep-me",
8015                "connect": {
8016                    "platforms": [
8017                        { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
8018                    ]
8019                }
8020            })
8021            .to_string(),
8022        );
8023
8024        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8025
8026        // In-memory: legacy value adopted.
8027        assert_eq!(config.connect.platforms.len(), 1);
8028        assert_eq!(
8029            config.connect.platforms[0].token.as_deref(),
8030            Some("legacy-connect-secret")
8031        );
8032        // An unrelated field from the same load survives the migration rewrite.
8033        assert_eq!(config.http_proxy, "http://keep-me");
8034
8035        // On disk: connect.json was created...
8036        assert!(
8037            connect_json_path(&temp).exists(),
8038            "migration proactively creates connect.json"
8039        );
8040        let connect_json: serde_json::Value =
8041            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8042                .unwrap();
8043        assert_eq!(
8044            connect_json["platforms"][0]["token_encrypted"],
8045            legacy_cipher_for_assert
8046        );
8047
8048        // ...and config.json was rewritten without the `connect` key.
8049        let config_json: serde_json::Value =
8050            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8051                .unwrap();
8052        assert!(
8053            config_json.get("connect").is_none(),
8054            "config.json is rewritten without the legacy `connect` key"
8055        );
8056    }
8057
8058    /// #457: the legacy-key migration must be a NARROW write (strip `connect`
8059    /// from config.json + write connect.json) — not the full `save_to_dir`,
8060    /// which would re-encrypt every OTHER secret in config.json and rotate a
8061    /// `config.json.bak` generation as a load-time side effect. This matters
8062    /// most for a purely READ-ONLY command (e.g. `bamboo config get`) run on a
8063    /// machine that still has the legacy `connect` key: it must not silently
8064    /// rewrite/re-encrypt unrelated secrets or spin up a backup.
8065    #[test]
8066    fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
8067        let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
8068        let temp = TempHome::new();
8069
8070        let original_api_key_encrypted =
8071            crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
8072        temp.set_config_json(
8073            &serde_json::json!({
8074                "providers": {
8075                    "openai": {
8076                        "api_key_encrypted": original_api_key_encrypted,
8077                    }
8078                },
8079                "connect": {
8080                    "platforms": [
8081                        { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
8082                    ]
8083                }
8084            })
8085            .to_string(),
8086        );
8087
8088        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8089        assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
8090
8091        // No `config.json.bak` — the narrow write does not rotate backups the
8092        // way a full `save_to_dir` would.
8093        assert!(
8094            !temp.path.join("config.json.bak").exists(),
8095            "a read-only load migrating a legacy `connect` key must not rotate \
8096             config.json backups"
8097        );
8098
8099        // The unrelated provider secret's ciphertext is byte-for-byte
8100        // unchanged — proof it was never decrypted+re-encrypted (encryption
8101        // uses a random nonce per call, so any re-encryption would change the
8102        // bytes even for the same plaintext).
8103        let config_json: serde_json::Value =
8104            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8105                .unwrap();
8106        assert_eq!(
8107            config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
8108            "an unrelated secret's ciphertext must not be touched by the connect \
8109             migration's narrow write"
8110        );
8111        assert!(
8112            config_json.get("connect").is_none(),
8113            "config.json is still rewritten without the legacy `connect` key"
8114        );
8115    }
8116
8117    #[test]
8118    fn both_files_present_connect_json_wins() {
8119        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
8120        let temp = TempHome::new();
8121        let stale = crate::encryption::encrypt("stale-secret").unwrap();
8122        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8123        temp.set_config_json(
8124            &serde_json::json!({
8125                "connect": {
8126                    "platforms": [
8127                        { "type": "telegram", "token_encrypted": stale }
8128                    ]
8129                }
8130            })
8131            .to_string(),
8132        );
8133        std::fs::write(
8134            connect_json_path(&temp),
8135            serde_json::json!({
8136                "platforms": [
8137                    { "type": "telegram", "token_encrypted": authoritative }
8138                ]
8139            })
8140            .to_string(),
8141        )
8142        .unwrap();
8143
8144        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8145        assert_eq!(
8146            config.connect.platforms[0].token.as_deref(),
8147            Some("authoritative-secret"),
8148            "connect.json wins over a stale legacy config.json key"
8149        );
8150    }
8151
8152    /// #457: when both files are present, the superseded `connect` key in
8153    /// config.json must be stripped PROACTIVELY on load — not left to linger
8154    /// until the next natural save, which spreads token ciphertext across two
8155    /// files for longer than necessary.
8156    #[test]
8157    fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
8158        let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
8159        let temp = TempHome::new();
8160        let stale = crate::encryption::encrypt("stale-secret").unwrap();
8161        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8162        temp.set_config_json(
8163            &serde_json::json!({
8164                "http_proxy": "http://keep-me",
8165                "connect": {
8166                    "platforms": [
8167                        { "type": "telegram", "token_encrypted": stale }
8168                    ]
8169                }
8170            })
8171            .to_string(),
8172        );
8173        std::fs::write(
8174            connect_json_path(&temp),
8175            serde_json::json!({
8176                "platforms": [
8177                    { "type": "telegram", "token_encrypted": authoritative }
8178                ]
8179            })
8180            .to_string(),
8181        )
8182        .unwrap();
8183
8184        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8185        assert_eq!(
8186            config.connect.platforms[0].token.as_deref(),
8187            Some("authoritative-secret")
8188        );
8189        // Unrelated field survives the strip.
8190        assert_eq!(config.http_proxy, "http://keep-me");
8191
8192        let config_json: serde_json::Value =
8193            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8194                .unwrap();
8195        assert!(
8196            config_json.get("connect").is_none(),
8197            "the stale legacy `connect` key must be stripped from config.json \
8198             immediately on load, not left for the next natural save"
8199        );
8200    }
8201
8202    /// #468 (follow-up to #457): a `.bak` generation that predates the #455
8203    /// split can still carry the legacy embedded `connect` sub-tree even
8204    /// after the LIVE config.json has long since been migrated (a clean
8205    /// config.json here, with no `connect` key at all, proves the sweep does
8206    /// not depend on the current-load migration path having just fired).
8207    /// Only the tainted generation is rewritten; every other key in it
8208    /// survives, and the rewrite strips exactly the `connect` key.
8209    #[test]
8210    fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
8211        let temp = TempHome::new();
8212        temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
8213        std::fs::write(
8214            temp.path.join("config.json.bak"),
8215            serde_json::json!({
8216                "http_proxy": "http://old",
8217                "connect": {
8218                    "platforms": [
8219                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
8220                    ]
8221                }
8222            })
8223            .to_string(),
8224        )
8225        .unwrap();
8226
8227        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8228        // The live config is unaffected — connect.json never existed and
8229        // config.json never had the key, so in-memory connect stays empty.
8230        assert!(config.connect.platforms.is_empty());
8231
8232        let bak: serde_json::Value = serde_json::from_str(
8233            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8234        )
8235        .unwrap();
8236        assert!(
8237            bak.get("connect").is_none(),
8238            "the legacy `connect` key must be stripped from the tainted .bak generation"
8239        );
8240        assert_eq!(
8241            bak["http_proxy"], "http://old",
8242            "every other key in the .bak generation survives the scrub byte-for-byte in content"
8243        );
8244    }
8245
8246    /// The sweep must touch EVERY rotated generation that carries the legacy
8247    /// key, not just `.bak` — an upgraded instance can have the taint several
8248    /// generations deep depending on how many saves happened since #455/#457
8249    /// shipped but before this fix.
8250    #[test]
8251    fn scrub_reaches_all_rotated_generations() {
8252        let temp = TempHome::new();
8253        temp.set_config_json(&serde_json::json!({}).to_string());
8254        for (gen_suffix, cipher) in [
8255            ("config.json.bak", "cipher-gen0"),
8256            ("config.json.bak.1", "cipher-gen1"),
8257            ("config.json.bak.2", "cipher-gen2"),
8258        ] {
8259            std::fs::write(
8260                temp.path.join(gen_suffix),
8261                serde_json::json!({
8262                    "connect": {
8263                        "platforms": [
8264                            { "type": "telegram", "token_encrypted": cipher }
8265                        ]
8266                    }
8267                })
8268                .to_string(),
8269            )
8270            .unwrap();
8271        }
8272
8273        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8274
8275        for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
8276            let value: serde_json::Value =
8277                serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
8278                    .unwrap();
8279            assert!(
8280                value.get("connect").is_none(),
8281                "{gen_suffix} must have its legacy `connect` key stripped"
8282            );
8283        }
8284    }
8285
8286    /// A `.bak` generation with NO legacy `connect` key must be left
8287    /// completely untouched by the sweep — not even a byte-identical
8288    /// rewrite — preserving the file's bytes/mtime exactly. This is the
8289    /// overwhelming common case (any backup created after #455/#457 shipped)
8290    /// and the whole point of the surgical, only-touch-what's-tainted
8291    /// approach: `.bak` files are the user's recovery net (#493) and
8292    /// shouldn't be churned by an unrelated sweep.
8293    #[test]
8294    fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
8295        let temp = TempHome::new();
8296        temp.set_config_json(&serde_json::json!({}).to_string());
8297        let bak_path = temp.path.join("config.json.bak");
8298        std::fs::write(
8299            &bak_path,
8300            serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
8301        )
8302        .unwrap();
8303
8304        let before_bytes = std::fs::read(&bak_path).unwrap();
8305        let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8306
8307        // A tiny sleep would make an mtime-changed assertion more robust, but
8308        // even without one, a same-mtime filesystem is the STRONGER
8309        // guarantee of "no write happened" — good enough on its own.
8310        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8311
8312        let after_bytes = std::fs::read(&bak_path).unwrap();
8313        let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8314        assert_eq!(
8315            before_bytes, after_bytes,
8316            "a .bak generation without a legacy `connect` key must not be rewritten at all"
8317        );
8318        assert_eq!(
8319            before_mtime, after_mtime,
8320            "no write means no mtime change either"
8321        );
8322    }
8323
8324    /// An unparsable `.bak` generation (corrupt/foreign content) must be
8325    /// skipped, not deleted and not guessed at — it's left exactly as found
8326    /// so an operator can inspect it by hand, matching the same fail-safe
8327    /// posture as the rest of the backup/quarantine machinery.
8328    #[test]
8329    fn scrub_skips_unparsable_backup_without_deleting_it() {
8330        let temp = TempHome::new();
8331        temp.set_config_json(&serde_json::json!({}).to_string());
8332        std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
8333
8334        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8335
8336        let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8337        assert_eq!(
8338            content, "{ not valid json",
8339            "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8340        );
8341    }
8342
8343    /// A missing generation (e.g. only `.bak` exists, no `.bak.1`/`.bak.2`
8344    /// yet) must not trip an error — it's the common case for a young
8345    /// install and the sweep should just skip straight past it.
8346    #[test]
8347    fn scrub_tolerates_missing_generations() {
8348        let temp = TempHome::new();
8349        temp.set_config_json(&serde_json::json!({}).to_string());
8350        // No .bak files at all.
8351        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8352        assert!(config.connect.platforms.is_empty());
8353        assert!(!temp.path.join("config.json.bak").exists());
8354    }
8355
8356    /// The scrub sweep must not interfere with normal backup rotation on
8357    /// subsequent saves — rotation keeps working exactly as before.
8358    #[test]
8359    fn scrub_does_not_break_backup_rotation() {
8360        let temp = TempHome::new();
8361        std::fs::write(
8362            temp.path.join("config.json"),
8363            serde_json::json!({
8364                "http_proxy": "http://proxy-v1",
8365                "connect": {
8366                    "platforms": [
8367                        { "type": "telegram", "token_encrypted": "legacy-cipher" }
8368                    ]
8369                }
8370            })
8371            .to_string(),
8372        )
8373        .unwrap();
8374        std::fs::write(
8375            temp.path.join("config.json.bak"),
8376            serde_json::json!({
8377                "http_proxy": "http://proxy-v0",
8378                "connect": {
8379                    "platforms": [
8380                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8381                    ]
8382                }
8383            })
8384            .to_string(),
8385        )
8386        .unwrap();
8387
8388        // Load triggers: migration of the live legacy key + the .bak sweep.
8389        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8390        let bak: serde_json::Value = serde_json::from_str(
8391            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8392        )
8393        .unwrap();
8394        assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8395
8396        // Rotation still works on a subsequent save: v_current -> .bak,
8397        // .bak(old) -> .bak.1.
8398        config.http_proxy = "http://proxy-v2".to_string();
8399        config.save_to_dir(temp.path.clone()).unwrap();
8400
8401        let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8402        assert!(
8403            new_bak.contains("proxy-v1"),
8404            ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8405        );
8406        let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8407        assert!(
8408            new_bak1.contains("proxy-v0"),
8409            ".bak.1 holds the scrubbed older generation after rotation"
8410        );
8411        assert!(
8412            !new_bak1.contains("legacy-bak-cipher"),
8413            "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8414             stripped secret"
8415        );
8416    }
8417
8418    #[test]
8419    fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8420        let temp = TempHome::new();
8421        temp.set_config_json("{}");
8422        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8423
8424        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8425        assert!(
8426            config.connect.platforms.is_empty(),
8427            "corrupt connect.json fails SAFE to an empty/inert connect config"
8428        );
8429
8430        let backup = connect_json_path(&temp).with_extension("json.bak");
8431        assert!(
8432            backup.exists(),
8433            "the corrupt connect.json is quarantined to connect.json.bak"
8434        );
8435        assert!(
8436            std::fs::read_to_string(backup)
8437                .unwrap()
8438                .contains("not valid json"),
8439            "the quarantined copy holds the bad content"
8440        );
8441        // #457: quarantine MOVES the corrupt file rather than copying it, so
8442        // the data dir doesn't end up with two copies of the same corrupt
8443        // content (the live `connect.json` and its `.bak`) sitting side by
8444        // side, which reads as confusing/ambiguous mid-incident.
8445        assert!(
8446            !connect_json_path(&temp).exists(),
8447            "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8448             connect.json should remain after quarantine"
8449        );
8450    }
8451
8452    #[test]
8453    fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8454        let temp = TempHome::new();
8455        // A legacy inline `connect` key is present too — it must NOT be used as a
8456        // fallback when connect.json is corrupt (security-sensitive: fail safe).
8457        temp.set_config_json(
8458            &serde_json::json!({
8459                "connect": {
8460                    "platforms": [
8461                        { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8462                    ]
8463                }
8464            })
8465            .to_string(),
8466        );
8467        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8468
8469        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8470        assert!(
8471            config.connect.platforms.is_empty(),
8472            "corrupt connect.json must not fall back to the legacy config.json copy"
8473        );
8474    }
8475
8476    #[test]
8477    fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8478        let temp = TempHome::new();
8479        let config = Config::create_default();
8480        assert!(config.connect.platforms.is_empty());
8481
8482        config
8483            .save_to_dir(temp.path.clone())
8484            .expect("save succeeds");
8485
8486        assert!(
8487            !connect_json_path(&temp).exists(),
8488            "an empty connect config with no pre-existing file must not create one"
8489        );
8490    }
8491
8492    #[test]
8493    fn connect_json_backed_up_before_overwrite() {
8494        let temp = TempHome::new();
8495        std::fs::write(
8496            connect_json_path(&temp),
8497            serde_json::json!({
8498                "platforms": [
8499                    { "type": "telegram", "token_encrypted": "old-cipher" }
8500                ]
8501            })
8502            .to_string(),
8503        )
8504        .unwrap();
8505
8506        let mut config = Config::create_default();
8507        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8508        config
8509            .save_to_dir(temp.path.clone())
8510            .expect("save succeeds");
8511
8512        let backup = connect_json_path(&temp).with_extension("json.bak");
8513        assert!(
8514            std::fs::read_to_string(backup)
8515                .unwrap()
8516                .contains("old-cipher"),
8517            "the previous connect.json is preserved as connect.json.bak before the overwrite"
8518        );
8519        let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8520        assert!(current.contains("new-cipher"));
8521    }
8522
8523    // ── Feishu adapter config fields (epic #447 phase 3, §2a) ───────────
8524
8525    #[test]
8526    fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8527        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8528        let temp = TempHome::new();
8529
8530        let mut config = Config::create_default();
8531        config.connect.platforms = vec![ConnectPlatformConfig {
8532            id: None,
8533            project_id: None,
8534            platform_type: "feishu".to_string(),
8535            token: None,
8536            token_encrypted: None,
8537            token_credential_ref: None,
8538            token_configured: false,
8539            app_id: Some("cli_real_app_id".to_string()),
8540            app_secret: Some("plain-app-secret".to_string()),
8541            app_secret_encrypted: None,
8542            app_secret_credential_ref: None,
8543            app_secret_configured: false,
8544            domain: Some("lark".to_string()),
8545            allow_from: vec!["ou_1".to_string()],
8546            admin_from: Vec::new(),
8547        }];
8548
8549        config
8550            .save_to_dir(temp.path.clone())
8551            .expect("save succeeds");
8552
8553        let connect_json: serde_json::Value =
8554            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8555                .unwrap();
8556        assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8557        assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8558        assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8559        assert!(
8560            connect_json["platforms"][0]["app_secret_encrypted"]
8561                .as_str()
8562                .is_some_and(|v| !v.is_empty()),
8563            "app_secret is persisted in its encrypted form in connect.json"
8564        );
8565        assert!(
8566            connect_json["platforms"][0].get("app_secret").is_none(),
8567            "the plaintext app_secret is never persisted (skip_serializing)"
8568        );
8569    }
8570
8571    #[test]
8572    fn load_hydrates_feishu_app_secret_from_encrypted() {
8573        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8574        let temp = TempHome::new();
8575
8576        let mut config = Config::create_default();
8577        config.connect.platforms = vec![ConnectPlatformConfig {
8578            id: None,
8579            project_id: None,
8580            platform_type: "feishu".to_string(),
8581            token: None,
8582            token_encrypted: None,
8583            token_credential_ref: None,
8584            token_configured: false,
8585            app_id: Some("cli_real_app_id".to_string()),
8586            app_secret: Some("plain-app-secret".to_string()),
8587            app_secret_encrypted: None,
8588            app_secret_credential_ref: None,
8589            app_secret_configured: false,
8590            domain: Some("lark".to_string()),
8591            allow_from: vec!["ou_1".to_string()],
8592            admin_from: Vec::new(),
8593        }];
8594        config
8595            .save_to_dir(temp.path.clone())
8596            .expect("save succeeds");
8597
8598        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8599        assert_eq!(reloaded.connect.platforms.len(), 1);
8600        assert_eq!(
8601            reloaded.connect.platforms[0].app_secret.as_deref(),
8602            Some("plain-app-secret"),
8603            "reload hydrates app_secret from app_secret_encrypted"
8604        );
8605        assert_eq!(
8606            reloaded.connect.platforms[0].app_id.as_deref(),
8607            Some("cli_real_app_id")
8608        );
8609        assert_eq!(
8610            reloaded.connect.platforms[0].domain.as_deref(),
8611            Some("lark")
8612        );
8613    }
8614
8615    #[test]
8616    fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8617        let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8618        let temp = TempHome::new();
8619        let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8620        std::fs::write(
8621            connect_json_path(&temp),
8622            serde_json::json!({
8623                "platforms": [
8624                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8625                ]
8626            })
8627            .to_string(),
8628        )
8629        .unwrap();
8630
8631        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8632
8633        assert_eq!(config.connect.platforms.len(), 1);
8634        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8635        assert_eq!(
8636            config.connect.platforms[0].token.as_deref(),
8637            Some("legacy-telegram-secret")
8638        );
8639        assert_eq!(
8640            config.connect.platforms[0].app_id, None,
8641            "a legacy entry with no Feishu fields deserializes them as None"
8642        );
8643        assert_eq!(config.connect.platforms[0].app_secret, None);
8644        assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8645        assert_eq!(config.connect.platforms[0].domain, None);
8646    }
8647
8648    #[test]
8649    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8650        let _lock = env_lock_acquire();
8651        let temp_home = TempHome::new();
8652        temp_home.set_config_json(
8653            r#"{
8654  "provider": "openai",
8655  "providers": {
8656    "openai": {
8657      "api_key": "sk-test",
8658      "model": "gpt-4o"
8659    }
8660  }
8661}"#,
8662        );
8663
8664        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8665        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8666
8667        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8668
8669        assert_eq!(
8670            config
8671                .providers
8672                .openai
8673                .as_ref()
8674                .and_then(|c| c.model.as_deref()),
8675            Some("gpt-4o")
8676        );
8677        assert!(
8678            config.http_proxy.is_empty(),
8679            "config should keep http_proxy empty when field is omitted"
8680        );
8681        assert!(
8682            config.https_proxy.is_empty(),
8683            "config should keep https_proxy empty when field is omitted"
8684        );
8685    }
8686
8687    #[test]
8688    fn get_memory_background_model_prefers_memory_specific_override() {
8689        let mut config = Config::default();
8690        config.features.provider_model_ref = false;
8691        config.provider = "openai".to_string();
8692        config.providers.openai = Some(OpenAIConfig {
8693            api_key: "test".to_string(),
8694            api_key_encrypted: None,
8695            credential_ref: None,
8696            base_url: None,
8697            model: Some("gpt-main".to_string()),
8698            fast_model: Some("gpt-fast".to_string()),
8699            vision_model: None,
8700            reasoning_effort: None,
8701            responses_only_models: vec![],
8702            request_overrides: None,
8703            extra: BTreeMap::new(),
8704            api_key_from_env: false,
8705        });
8706        config.memory.0 = Some(MemoryConfig {
8707            background_model: Some("memory-fast".to_string()),
8708            ..MemoryConfig::default()
8709        });
8710
8711        assert_eq!(
8712            config.get_memory_background_model().as_deref(),
8713            Some("memory-fast")
8714        );
8715    }
8716
8717    #[test]
8718    fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8719        // #373: the settings-PATCH serde round-trip drops every provider's
8720        // skip_serializing api_key; an env-sourced key (no ciphertext) can't be
8721        // re-hydrated, so it must be copied back from the live `current` config —
8722        // but an explicitly re-set key and non-env keys must NOT be touched.
8723        let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8724            api_key: api_key.to_string(),
8725            api_key_encrypted: None,
8726            credential_ref: None,
8727            base_url: None,
8728            model: None,
8729            fast_model: None,
8730            vision_model: None,
8731            reasoning_effort: None,
8732            responses_only_models: vec![],
8733            request_overrides: None,
8734            extra: BTreeMap::new(),
8735            api_key_from_env: from_env,
8736        };
8737
8738        // Env-sourced key dropped by the round-trip → restored.
8739        let mut current = Config::default();
8740        current.providers.openai = Some(openai("sk-env", true));
8741        let mut merged = Config::default();
8742        merged.providers.openai = Some(openai("", false)); // post-round-trip
8743        merged.preserve_env_sourced_provider_keys(&current);
8744        let got = merged.providers.openai.as_ref().unwrap();
8745        assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8746        assert!(got.api_key_from_env, "env flag restored");
8747
8748        // A key explicitly re-set by the patch is NOT overridden.
8749        let mut merged = Config::default();
8750        merged.providers.openai = Some(openai("sk-explicit", false));
8751        merged.preserve_env_sourced_provider_keys(&current);
8752        assert_eq!(
8753            merged.providers.openai.as_ref().unwrap().api_key,
8754            "sk-explicit",
8755            "explicit patch key must win"
8756        );
8757
8758        // A non-env key in current is NOT restored here (that's ciphertext hydration's job).
8759        let mut current_plain = Config::default();
8760        current_plain.providers.openai = Some(openai("sk-plain", false));
8761        let mut merged = Config::default();
8762        merged.providers.openai = Some(openai("", false));
8763        merged.preserve_env_sourced_provider_keys(&current_plain);
8764        assert!(
8765            merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8766            "non-env key must not be restored by this path"
8767        );
8768    }
8769
8770    #[test]
8771    fn refresh_preserves_ciphertext_when_plaintext_empty() {
8772        // #268: a provider whose stored ciphertext failed to decrypt at hydration
8773        // has an empty in-memory api_key. An unrelated later save must NOT null its
8774        // ciphertext — that would permanently drop a key the user never touched.
8775        let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8776            api_key: api_key.to_string(),
8777            api_key_encrypted: enc.map(str::to_string),
8778            credential_ref: None,
8779            base_url: None,
8780            model: None,
8781            fast_model: None,
8782            vision_model: None,
8783            reasoning_effort: None,
8784            responses_only_models: vec![],
8785            request_overrides: None,
8786            extra: BTreeMap::new(),
8787            api_key_from_env: false,
8788        };
8789
8790        // Empty plaintext + existing ciphertext → ciphertext preserved (the bug).
8791        let mut config = Config::default();
8792        config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8793        config
8794            .refresh_provider_api_keys_encrypted()
8795            .expect("refresh");
8796        assert_eq!(
8797            config
8798                .providers
8799                .openai
8800                .as_ref()
8801                .unwrap()
8802                .api_key_encrypted
8803                .as_deref(),
8804            Some("preexisting-ciphertext"),
8805            "existing ciphertext must be preserved when plaintext is empty"
8806        );
8807
8808        // Empty plaintext + no ciphertext → stays None (nothing to preserve).
8809        let mut config = Config::default();
8810        config.providers.openai = Some(openai("", None));
8811        config
8812            .refresh_provider_api_keys_encrypted()
8813            .expect("refresh");
8814        assert!(
8815            config
8816                .providers
8817                .openai
8818                .as_ref()
8819                .unwrap()
8820                .api_key_encrypted
8821                .is_none(),
8822            "no key + no ciphertext should stay None"
8823        );
8824
8825        // Non-empty plaintext → (re)encrypted to a fresh, non-empty ciphertext.
8826        let mut config = Config::default();
8827        config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8828        config
8829            .refresh_provider_api_keys_encrypted()
8830            .expect("refresh");
8831        let enc = config
8832            .providers
8833            .openai
8834            .as_ref()
8835            .unwrap()
8836            .api_key_encrypted
8837            .clone()
8838            .expect("ciphertext present");
8839        assert!(
8840            !enc.is_empty() && enc != "stale-ciphertext",
8841            "plaintext re-encrypted"
8842        );
8843    }
8844
8845    #[test]
8846    fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8847        // #516: `save_to_dir` refreshes ciphertext only on its save-time clone,
8848        // so a provider instance created over HTTP stays plaintext-only in the
8849        // live config. Serializing that live config (as the settings-PATCH
8850        // merge does) drops the `skip_serializing` plaintext and the key is
8851        // gone. `refresh_encrypted_secrets` on the live config closes the gap.
8852        let mut config = Config::default();
8853        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8854            "provider_type": "openai",
8855            "api_key": "sk-instance-live",
8856        }))
8857        .expect("valid instance");
8858        config
8859            .provider_instances
8860            .insert("work".to_string(), instance);
8861
8862        config.refresh_encrypted_secrets().expect("refresh");
8863        assert!(
8864            config.provider_instances["work"]
8865                .api_key_encrypted
8866                .is_some(),
8867            "live config must hold ciphertext after refresh"
8868        );
8869
8870        // The build_merged_config-style round-trip.
8871        let value = serde_json::to_value(&config).expect("serialize");
8872        let mut back: Config = serde_json::from_value(value).expect("deserialize");
8873        assert!(
8874            back.provider_instances["work"].api_key.is_empty(),
8875            "plaintext is skip_serializing"
8876        );
8877        back.hydrate_provider_instance_api_keys_from_encrypted();
8878        assert_eq!(
8879            back.provider_instances["work"].api_key, "sk-instance-live",
8880            "key must be recoverable from the round-tripped ciphertext"
8881        );
8882    }
8883
8884    #[test]
8885    fn get_memory_background_model_falls_back_to_provider_fast_model() {
8886        let mut config = Config::default();
8887        config.features.provider_model_ref = false;
8888        config.provider = "openai".to_string();
8889        config.providers.openai = Some(OpenAIConfig {
8890            api_key: "test".to_string(),
8891            api_key_encrypted: None,
8892            credential_ref: None,
8893            base_url: None,
8894            model: Some("gpt-main".to_string()),
8895            fast_model: Some("gpt-fast".to_string()),
8896            vision_model: None,
8897            reasoning_effort: None,
8898            responses_only_models: vec![],
8899            request_overrides: None,
8900            extra: BTreeMap::new(),
8901            api_key_from_env: false,
8902        });
8903
8904        assert_eq!(
8905            config.get_memory_background_model().as_deref(),
8906            Some("gpt-fast")
8907        );
8908    }
8909
8910    #[test]
8911    fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8912        let mut config = Config::default();
8913        config.features.provider_model_ref = false;
8914        config.provider = "openai".to_string();
8915        config.providers.openai = Some(OpenAIConfig {
8916            api_key: "sk-stale".to_string(),
8917            model: Some("legacy-main".to_string()),
8918            fast_model: Some("legacy-fast".to_string()),
8919            vision_model: Some("legacy-vision".to_string()),
8920            reasoning_effort: Some(ReasoningEffort::Low),
8921            ..OpenAIConfig::default()
8922        });
8923        let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8924            "provider_type": "openai",
8925            "model": "instance-main",
8926            "fast_model": "instance-fast",
8927            "vision_model": "instance-vision",
8928            "reasoning_effort": "high",
8929            "enabled": true
8930        }))
8931        .unwrap();
8932        instance.api_key = "sk-instance".to_string();
8933        config
8934            .provider_instances
8935            .insert("work".to_string(), instance);
8936        config.default_provider_instance = Some("work".to_string());
8937
8938        assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8939        assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8940        assert_eq!(
8941            config.get_memory_background_model().as_deref(),
8942            Some("instance-fast")
8943        );
8944        assert_eq!(
8945            config.get_task_summary_model().as_deref(),
8946            Some("instance-fast")
8947        );
8948        assert_eq!(
8949            config.get_vision_model().as_deref(),
8950            Some("instance-vision")
8951        );
8952        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8953    }
8954
8955    #[test]
8956    fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8957        let mut config = Config::default();
8958        config.features.provider_model_ref = true;
8959        config.provider_instances.insert(
8960            "compatibility-default".to_string(),
8961            serde_json::from_value(serde_json::json!({
8962                "provider_type": "openai",
8963                "reasoning_effort": "low",
8964                "enabled": true
8965            }))
8966            .unwrap(),
8967        );
8968        config.provider_instances.insert(
8969            "chat-provider".to_string(),
8970            serde_json::from_value(serde_json::json!({
8971                "provider_type": "anthropic",
8972                "reasoning_effort": "high",
8973                "enabled": true
8974            }))
8975            .unwrap(),
8976        );
8977        config.default_provider_instance = Some("compatibility-default".to_string());
8978        config.defaults = Some(
8979            serde_json::from_value(serde_json::json!({
8980                "chat": { "provider": "chat-provider", "model": "claude" }
8981            }))
8982            .unwrap(),
8983        );
8984
8985        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8986
8987        config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8988        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8989    }
8990
8991    #[test]
8992    fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8993        let _provider =
8994            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8995        let _openai_key = crate::test_support::override_runtime_env_var(
8996            "BAMBOO_OPENAI_API_KEY",
8997            Some("sk-runtime-env-instance"),
8998        );
8999        let _anthropic_key =
9000            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
9001        let _gemini_key =
9002            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9003
9004        let mut config = Config::default();
9005        *config.providers_mut() = ProviderConfigs::default();
9006        for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
9007            let mut extra = serde_json::Map::new();
9008            if from_environment {
9009                extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
9010            }
9011            extra.insert("provider_type".to_string(), serde_json::json!("openai"));
9012            extra.insert("enabled".to_string(), serde_json::json!(true));
9013            config.provider_instances.insert(
9014                id.to_string(),
9015                serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
9016            );
9017        }
9018
9019        config.apply_runtime_env_overrides();
9020
9021        assert_eq!(
9022            config.default_provider_instance.as_deref(),
9023            Some("a-marked"),
9024            "type override selects the lexicographically first enabled instance"
9025        );
9026        assert_eq!(
9027            config.provider_instances["a-marked"].api_key,
9028            "sk-runtime-env-instance"
9029        );
9030        assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
9031        assert!(
9032            config.providers().openai.is_none(),
9033            "instance-mode env hydration must not recreate a legacy alias"
9034        );
9035
9036        config.refresh_encrypted_secrets().unwrap();
9037        let serialized = serde_json::to_string(&config).unwrap();
9038        assert!(!serialized.contains("sk-runtime-env-instance"));
9039        assert!(
9040            config.provider_instances["a-marked"]
9041                .api_key_encrypted
9042                .is_none(),
9043            "runtime env plaintext must not be encrypted into ordinary config"
9044        );
9045    }
9046
9047    #[test]
9048    fn runtime_provider_override_prefers_exact_instance_id() {
9049        let _provider =
9050            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
9051        let _openai_key =
9052            crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
9053        let _anthropic_key =
9054            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
9055        let _gemini_key =
9056            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9057        let mut config = Config::default();
9058        for id in ["work", "personal"] {
9059            config.provider_instances.insert(
9060                id.to_string(),
9061                serde_json::from_value(serde_json::json!({
9062                    "provider_type": "openai",
9063                    "enabled": true
9064                }))
9065                .unwrap(),
9066            );
9067        }
9068
9069        config.apply_runtime_env_overrides();
9070        assert_eq!(
9071            config.default_provider_instance.as_deref(),
9072            Some("personal")
9073        );
9074    }
9075
9076    #[test]
9077    fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
9078        let mut config = Config::default();
9079        config.features.provider_model_ref = false;
9080        config.provider = "openai".to_string();
9081        config.providers.openai = Some(OpenAIConfig {
9082            api_key: "test".to_string(),
9083            api_key_encrypted: None,
9084            credential_ref: None,
9085            base_url: None,
9086            model: Some("gpt-main".to_string()),
9087            fast_model: None,
9088            vision_model: None,
9089            reasoning_effort: None,
9090            responses_only_models: vec![],
9091            request_overrides: None,
9092            extra: BTreeMap::new(),
9093            api_key_from_env: false,
9094        });
9095
9096        assert_eq!(
9097            config.get_memory_background_model().as_deref(),
9098            Some("gpt-main")
9099        );
9100    }
9101
9102    #[test]
9103    fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
9104        let legacy = serde_json::json!({
9105            "memory": MemoryConfig {
9106                background_model: Some("dream-fast".to_string()),
9107                summary_target_ratio: 0.20,
9108                summary_safe_window_percent: 80,
9109                auto_dream_enabled: true,
9110                auto_dream_interval_secs: 900,
9111                project_prompt_injection: false,
9112                relevant_recall: false,
9113                relevant_recall_rerank: true,
9114                project_first_dream: false,
9115                ledger_agenda_injection: false,
9116                ledger_gardener_enabled: false,
9117                ledger_gardener_interval_secs: 7_200,
9118                ledger_distillation_enabled: false,
9119                dream_refine_mode: true,
9120                gardener_enabled: true,
9121                gardener_interval_secs: 3_600,
9122                gardener_volume_trigger: 40,
9123                gardener_max_splits_per_run: 4,
9124                gardener_min_sections: 7,
9125                dedup_gardener_enabled: true,
9126                dedup_gardener_min_score: 0.7,
9127                dedup_gardener_max_merges_per_run: 3,
9128                memory_active_capacity: 500,
9129                capacity_max_archivals_per_run: 10,
9130                granularity_freshness_gardener_enabled: false,
9131            }
9132        });
9133        let config: Config = serde_json::from_value(legacy).unwrap();
9134
9135        let serialized = serde_json::to_value(&config).expect("config should serialize");
9136        assert!(serialized.get("memory").is_some());
9137        let round_tripped: Config = serde_json::from_value(serialized).unwrap();
9138        assert!(round_tripped
9139            .memory()
9140            .as_ref()
9141            .is_some_and(|memory| memory.dream_refine_mode));
9142        let memory = config.memory.as_ref().expect("memory config should exist");
9143        assert!(memory.auto_dream_enabled);
9144        assert!(!memory.project_prompt_injection);
9145        assert!(!memory.relevant_recall);
9146        assert!(memory.relevant_recall_rerank);
9147        assert!(!memory.project_first_dream);
9148        assert!(memory.dream_refine_mode);
9149        assert!(memory.gardener_enabled);
9150        assert_eq!(memory.gardener_interval_secs, 3_600);
9151        assert_eq!(memory.gardener_volume_trigger, 40);
9152        assert_eq!(memory.gardener_max_splits_per_run, 4);
9153        assert_eq!(memory.gardener_min_sections, 7);
9154        assert!(memory.dedup_gardener_enabled);
9155        assert_eq!(memory.dedup_gardener_min_score, 0.7);
9156        assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
9157        assert_eq!(memory.memory_active_capacity, 500);
9158        assert_eq!(memory.capacity_max_archivals_per_run, 10);
9159        assert!(!memory.granularity_freshness_gardener_enabled);
9160    }
9161
9162    /// L5: capacity is OFF by default (0 = unbounded) — an opt-in feature.
9163    #[test]
9164    fn memory_active_capacity_defaults_off() {
9165        assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
9166        assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
9167        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9168        let memory = parsed.memory.as_ref().unwrap();
9169        assert_eq!(memory.memory_active_capacity, 0);
9170        assert_eq!(
9171            memory.capacity_max_archivals_per_run, 50,
9172            "omitted field takes the serde default fn"
9173        );
9174    }
9175
9176    #[test]
9177    fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
9178        let defaults = MemoryConfig::default();
9179        assert_eq!(defaults.summary_target_ratio, 0.20);
9180        assert_eq!(defaults.summary_safe_window_percent, 80);
9181
9182        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9183        let memory = parsed.memory.as_ref().expect("memory present");
9184        assert_eq!(memory.summary_target_ratio, 0.20);
9185        assert_eq!(memory.summary_safe_window_percent, 80);
9186    }
9187
9188    /// L4: the maintenance integrators are ON by default — both via
9189    /// `MemoryConfig::default()` AND when a config file omits the flags entirely
9190    /// (serde `default = fn`, not the bare `#[serde(default)]` = `false`).
9191    #[test]
9192    fn memory_maintenance_integrators_default_on() {
9193        let defaults = MemoryConfig::default();
9194        assert!(defaults.auto_dream_enabled);
9195        assert!(defaults.gardener_enabled);
9196        assert!(defaults.dedup_gardener_enabled);
9197        assert_eq!(defaults.gardener_volume_trigger, 25);
9198
9199        // A config that mentions `memory` but omits the flags must still be ON.
9200        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9201        let memory = parsed.memory.as_ref().expect("memory present");
9202        assert!(
9203            memory.auto_dream_enabled,
9204            "auto_dream on when field omitted"
9205        );
9206        assert!(memory.gardener_enabled, "gardener on when field omitted");
9207        assert!(
9208            memory.dedup_gardener_enabled,
9209            "dedup gardener on when field omitted"
9210        );
9211        // An explicit opt-out is still honored.
9212        let opted_out: Config =
9213            serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
9214        assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
9215    }
9216
9217    #[test]
9218    fn memory_config_env_overrides_prompt_flags() {
9219        let _lock = env_lock_acquire();
9220        let temp_home = TempHome::new();
9221        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9222        let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
9223        let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
9224        let _relevant_recall_rerank =
9225            EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
9226        let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
9227
9228        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9229        let memory = config
9230            .memory
9231            .as_ref()
9232            .expect("memory config should be created by env overrides");
9233        assert!(!memory.project_prompt_injection);
9234        assert!(!memory.relevant_recall);
9235        assert!(memory.relevant_recall_rerank);
9236        assert!(!memory.project_first_dream);
9237    }
9238
9239    #[test]
9240    fn provider_api_keys_injected_from_env_and_never_persisted() {
9241        let _lock = env_lock_acquire();
9242        let temp_home = TempHome::new();
9243        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9244        let _anthropic = crate::test_support::override_runtime_env_var(
9245            "BAMBOO_ANTHROPIC_API_KEY",
9246            Some("sk-ant-from-env"),
9247        );
9248        let _openai = crate::test_support::override_runtime_env_var(
9249            "BAMBOO_OPENAI_API_KEY",
9250            Some("sk-oai-from-env"),
9251        );
9252        let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9253
9254        // No config.json on disk → the providers are created from the env keys
9255        // alone (#253: deploy without a plaintext api_key in a mounted file).
9256        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9257        assert_eq!(
9258            config
9259                .providers
9260                .anthropic
9261                .as_ref()
9262                .expect("anthropic created from env")
9263                .api_key,
9264            "sk-ant-from-env"
9265        );
9266        assert_eq!(
9267            config
9268                .providers
9269                .openai
9270                .as_ref()
9271                .expect("openai created from env")
9272                .api_key,
9273            "sk-oai-from-env"
9274        );
9275        // An unset provider is not fabricated.
9276        assert!(config.providers.gemini.is_none());
9277
9278        // The real "never persisted" guarantee: saving the config must NOT bake
9279        // the env key into config.json — not as plaintext AND not re-encrypted
9280        // into `api_key_encrypted` (which save's `refresh_provider_api_keys_encrypted`
9281        // would otherwise do). This is what actually happens on the server when
9282        // any unrelated setting is saved / on a fabric-reconcile boot.
9283        config
9284            .save_to_dir(temp_home.path.clone())
9285            .expect("save config");
9286        let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
9287            .expect("read persisted config.json");
9288        assert!(
9289            !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
9290            "env key must not be persisted as plaintext"
9291        );
9292        let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
9293        assert!(
9294            disk_json["providers"]["anthropic"]
9295                .get("api_key_encrypted")
9296                .is_none(),
9297            "env-sourced anthropic key must not be re-encrypted into config.json"
9298        );
9299        assert!(
9300            disk_json["providers"]["openai"]
9301                .get("api_key_encrypted")
9302                .is_none(),
9303            "env-sourced openai key must not be re-encrypted into config.json"
9304        );
9305
9306        // And once the env vars are gone, a reload from that same dir has no key
9307        // (nothing was persisted).
9308        drop(_anthropic);
9309        drop(_openai);
9310        let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
9311        assert!(reloaded
9312            .providers
9313            .anthropic
9314            .as_ref()
9315            .map(|a| a.api_key.is_empty())
9316            .unwrap_or(true));
9317    }
9318
9319    #[test]
9320    fn get_default_work_area_path_expands_tilde_and_requires_directory() {
9321        let _lock = env_lock_acquire();
9322        let temp_home = TempHome::new();
9323        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9324        let target = temp_home.path.join("workspace-default");
9325        std::fs::create_dir_all(&target).expect("default work area dir should exist");
9326
9327        let mut config = Config::default();
9328        config.default_work_area.replace(DefaultWorkAreaConfig {
9329            path: Some("~/workspace-default".to_string()),
9330        });
9331
9332        assert_eq!(config.get_default_work_area_path(), Some(target));
9333    }
9334
9335    #[test]
9336    fn get_default_work_area_path_returns_none_for_missing_directory() {
9337        let _lock = env_lock_acquire();
9338        let temp_home = TempHome::new();
9339        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9340
9341        let mut config = Config::default();
9342        config.default_work_area.replace(DefaultWorkAreaConfig {
9343            path: Some("~/missing-default-work-area".to_string()),
9344        });
9345
9346        assert!(config.get_default_work_area_path().is_none());
9347    }
9348
9349    #[test]
9350    fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9351        let mut config = Config::default();
9352        config.tools.disabled = vec![
9353            "  read_file  ".to_string(),
9354            "".to_string(),
9355            "read_file".to_string(),
9356            "bash".to_string(),
9357            "default::getCurrentDir".to_string(),
9358            "default::applyPatch".to_string(),
9359            "default::custom_tool".to_string(),
9360            "mcp__alpha__inspect".to_string(),
9361        ];
9362
9363        config.normalize_tool_settings();
9364
9365        assert_eq!(
9366            config.tools.disabled,
9367            vec![
9368                "bash",
9369                "default::applyPatch",
9370                "default::custom_tool",
9371                "default::getCurrentDir",
9372                "mcp__alpha__inspect",
9373                "read_file"
9374            ]
9375        );
9376    }
9377
9378    #[test]
9379    fn config_load_preserves_disabled_references_for_catalog_resolution() {
9380        let _lock = env_lock_acquire();
9381        let temp_home = TempHome::new();
9382        temp_home.set_config_json(
9383            r#"{
9384  "tools": {
9385    "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9386  }
9387}"#,
9388        );
9389
9390        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9391        assert_eq!(
9392            config.tools.disabled,
9393            vec!["bash", "default::getCurrentDir", "read_file"]
9394        );
9395        assert!(config.disabled_tool_references().contains("bash"));
9396        assert!(config.disabled_tool_references().contains("read_file"));
9397        assert!(config
9398            .disabled_tool_references()
9399            .contains("default::getCurrentDir"));
9400        assert_eq!(
9401            config.disabled_tool_names(),
9402            BTreeSet::from([
9403                "Bash".to_string(),
9404                "GetCurrentDir".to_string(),
9405                "Read".to_string()
9406            ])
9407        );
9408    }
9409
9410    #[test]
9411    fn normalize_skill_settings_trims_dedupes_and_sorts() {
9412        let mut config = Config::default();
9413        config.skills.disabled = vec![
9414            " pdf ".to_string(),
9415            "".to_string(),
9416            "pdf".to_string(),
9417            "skill-creator".to_string(),
9418        ];
9419
9420        config.normalize_skill_settings();
9421
9422        assert_eq!(
9423            config.skills.disabled,
9424            vec!["pdf".to_string(), "skill-creator".to_string()]
9425        );
9426    }
9427
9428    #[test]
9429    fn config_load_reads_disabled_skills_as_normalized_ids() {
9430        let _lock = env_lock_acquire();
9431        let temp_home = TempHome::new();
9432        temp_home.set_config_json(
9433            r#"{
9434  "skills": {
9435    "disabled": [" pdf ", "skill-creator", "pdf", ""]
9436  }
9437}"#,
9438        );
9439
9440        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9441        assert_eq!(
9442            config.skills.disabled,
9443            vec!["pdf".to_string(), "skill-creator".to_string()]
9444        );
9445        assert!(config.disabled_skill_ids().contains("pdf"));
9446        assert!(config.disabled_skill_ids().contains("skill-creator"));
9447    }
9448
9449    #[test]
9450    fn test_server_config_defaults() {
9451        let _lock = env_lock_acquire();
9452        let temp_home = TempHome::new();
9453
9454        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9455        assert_eq!(config.server.port, 9562);
9456        assert_eq!(config.server.bind, "127.0.0.1");
9457        assert_eq!(config.server.workers, 10);
9458        assert!(config.server.static_dir.is_none());
9459    }
9460
9461    #[test]
9462    fn test_server_addr() {
9463        let mut config = Config::default();
9464        config.server.port = 9000;
9465        config.server.bind = "0.0.0.0".to_string();
9466        assert_eq!(config.server_addr(), "0.0.0.0:9000");
9467    }
9468
9469    #[test]
9470    fn test_env_var_overrides() {
9471        let _lock = env_lock_acquire();
9472        let temp_home = TempHome::new();
9473
9474        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9475        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9476        let _provider =
9477            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9478
9479        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9480        assert_eq!(config.server.port, 9999);
9481        assert_eq!(config.server.bind, "192.168.1.1");
9482        assert_eq!(config.provider, "openai");
9483    }
9484
9485    #[test]
9486    fn test_config_save_and_load() {
9487        let _lock = env_lock_acquire();
9488        let temp_home = TempHome::new();
9489
9490        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9491        config.server.port = 9000;
9492        config.server.bind = "0.0.0.0".to_string();
9493        config.provider = "anthropic".to_string();
9494
9495        // Save
9496        config
9497            .save_to_dir(temp_home.path.clone())
9498            .expect("Failed to save config");
9499
9500        // Load again
9501        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9502
9503        // Verify
9504        assert_eq!(loaded.server.port, 9000);
9505        assert_eq!(loaded.server.bind, "0.0.0.0");
9506        assert_eq!(loaded.provider, "anthropic");
9507    }
9508
9509    #[test]
9510    fn modular_root_and_public_serde_preserve_legacy_shape() {
9511        let _lock = env_lock_acquire();
9512        let temp_home = TempHome::new();
9513        let input = serde_json::json!({
9514            "http_proxy": "http://proxy.example",
9515            "provider": "openai",
9516            "mcp": { "servers": [] },
9517            "future_extension": { "enabled": true },
9518            "memory": { "background_model": "memory-model" },
9519            "subagents": { "max_concurrent": 7 },
9520            "providers": { "openai": { "model": "chat-model" } }
9521        });
9522
9523        let config: Config = serde_json::from_value(input).unwrap();
9524        let public = serde_json::to_value(&config).unwrap();
9525        assert_eq!(public["http_proxy"], "http://proxy.example");
9526        assert!(public.get("mcp").is_none());
9527        assert!(public.get("mcpServers").is_some());
9528        assert_eq!(public["future_extension"]["enabled"], true);
9529        assert_eq!(public["memory"]["background_model"], "memory-model");
9530        assert_eq!(public["subagents"]["max_concurrent"], 7);
9531        assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9532
9533        let round_tripped: Config = serde_json::from_value(public).unwrap();
9534        assert_eq!(
9535            round_tripped
9536                .memory()
9537                .as_ref()
9538                .unwrap()
9539                .background_model
9540                .as_deref(),
9541            Some("memory-model")
9542        );
9543        assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9544        assert_eq!(
9545            round_tripped
9546                .providers()
9547                .openai
9548                .as_ref()
9549                .unwrap()
9550                .model
9551                .as_deref(),
9552            Some("chat-model")
9553        );
9554        assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9555
9556        round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9557        let persisted: Value =
9558            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9559                .unwrap();
9560        assert_eq!(persisted["http_proxy"], "http://proxy.example");
9561        assert!(persisted.get("mcpServers").is_some());
9562        assert_eq!(persisted["future_extension"]["enabled"], true);
9563        assert!(persisted.get("memory").is_none());
9564        assert!(persisted.get("subagents").is_none());
9565        assert!(persisted.get("providers").is_none());
9566        for internal_section_name in [
9567            "network",
9568            "provider_routing",
9569            "model_behavior",
9570            "tooling",
9571            "workspace",
9572            "execution",
9573            "integrations",
9574            "plugin_security",
9575        ] {
9576            assert!(persisted.get(internal_section_name).is_none());
9577        }
9578    }
9579
9580    #[test]
9581    fn config_decrypts_proxy_auth_from_encrypted_field() {
9582        let _lock = env_lock_acquire();
9583        let temp_home = TempHome::new();
9584
9585        // Use a stable encryption key so this test doesn't depend on host identifiers.
9586        let key_guard = crate::encryption::set_test_encryption_key([
9587            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9588            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9589            0x1c, 0x1d, 0x1e, 0x1f,
9590        ]);
9591
9592        let auth = ProxyAuth {
9593            username: "user".to_string(),
9594            password: "pass".to_string(),
9595        };
9596        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9597        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9598
9599        temp_home.set_config_json(&format!(
9600            r#"{{
9601  "http_proxy": "http://proxy.example.com:8080",
9602  "proxy_auth_encrypted": "{encrypted}"
9603}}"#
9604        ));
9605        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9606        let loaded_auth = config
9607            .proxy_auth
9608            .as_ref()
9609            .expect("proxy auth should be hydrated");
9610        assert_eq!(loaded_auth.username, "user");
9611        assert_eq!(loaded_auth.password, "pass");
9612        drop(key_guard);
9613    }
9614
9615    #[test]
9616    fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9617        let _lock = env_lock_acquire();
9618        let temp_home = TempHome::new();
9619
9620        // Use a stable encryption key so this test doesn't depend on host identifiers.
9621        let key_guard = crate::encryption::set_test_encryption_key([
9622            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9623            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9624            0x1c, 0x1d, 0x1e, 0x1f,
9625        ]);
9626
9627        let auth = ProxyAuth {
9628            username: "user".to_string(),
9629            password: "pass".to_string(),
9630        };
9631        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9632        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9633
9634        // Simulate older Bodhi/Tauri persisted config keys.
9635        temp_home.set_config_json(&format!(
9636            r#"{{
9637  "http_proxy": "http://proxy.example.com:8080",
9638  "http_proxy_auth_encrypted": "{encrypted}",
9639  "https_proxy_auth_encrypted": "{encrypted}"
9640}}"#
9641        ));
9642
9643        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9644        let loaded_auth = config
9645            .proxy_auth
9646            .as_ref()
9647            .expect("proxy auth should be hydrated");
9648        assert_eq!(loaded_auth.username, "user");
9649        assert_eq!(loaded_auth.password, "pass");
9650        drop(key_guard);
9651    }
9652
9653    #[test]
9654    fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9655        let _lock = env_lock_acquire();
9656        let temp_home = TempHome::new();
9657
9658        // Use a stable encryption key so this test doesn't depend on host identifiers.
9659        let key_guard = crate::encryption::set_test_encryption_key([
9660            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9661            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9662            0x1c, 0x1d, 0x1e, 0x1f,
9663        ]);
9664
9665        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9666        config.proxy_auth = Some(ProxyAuth {
9667            username: "user".to_string(),
9668            password: "pass".to_string(),
9669        });
9670        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9671        assert!(error
9672            .to_string()
9673            .contains("isolated credential transaction"));
9674        let path = temp_home.path.join("config.json");
9675        assert!(
9676            !path.exists(),
9677            "a rejected unisolated secret must not create config.json"
9678        );
9679        drop(key_guard);
9680    }
9681
9682    #[test]
9683    fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9684        let _lock = env_lock_acquire();
9685        let temp_home = TempHome::new();
9686        let mut config = Config::default();
9687        config.env_vars.push(EnvVarEntry {
9688            name: "TOKEN".to_string(),
9689            value: String::new(),
9690            secret: true,
9691            value_encrypted: None,
9692            credential_ref: None,
9693            configured: true,
9694            description: None,
9695        });
9696        let path = temp_home.path.join("config.json");
9697
9698        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9699        assert!(error
9700            .to_string()
9701            .contains("isolated credential transaction"));
9702        assert!(
9703            !path.exists(),
9704            "a rejected dangling ref must not create config.json"
9705        );
9706
9707        let original = br#"{"preserve":"original"}"#;
9708        std::fs::write(&path, original).unwrap();
9709        config.save_to_dir(temp_home.path.clone()).unwrap_err();
9710        assert_eq!(
9711            std::fs::read(&path).unwrap(),
9712            original,
9713            "a rejected dangling ref must not modify an existing config.json"
9714        );
9715    }
9716
9717    #[test]
9718    fn config_save_persists_provider_reference_and_isolates_plaintext() {
9719        let _lock = env_lock_acquire();
9720        let temp_home = TempHome::new();
9721
9722        // Use a stable encryption key so this test doesn't depend on host identifiers.
9723        let key_guard = crate::encryption::set_test_encryption_key([
9724            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9725            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9726            0x1c, 0x1d, 0x1e, 0x1f,
9727        ]);
9728
9729        let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9730        crate::CredentialStore::open(&temp_home.path)
9731            .replace(
9732                reference.clone(),
9733                "sk-test-provider-key",
9734                crate::CredentialSource::User,
9735                0,
9736            )
9737            .unwrap();
9738        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9739        config.provider = "openai".to_string();
9740        config.providers.openai = Some(OpenAIConfig {
9741            api_key: "sk-test-provider-key".to_string(),
9742            api_key_encrypted: None,
9743            credential_ref: Some(reference),
9744            base_url: None,
9745            model: None,
9746            fast_model: None,
9747            vision_model: None,
9748            reasoning_effort: None,
9749            responses_only_models: vec![],
9750            request_overrides: None,
9751            extra: Default::default(),
9752            api_key_from_env: false,
9753        });
9754
9755        config
9756            .save_to_dir(temp_home.path.clone())
9757            .expect("save should persist provider reference");
9758
9759        let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9760            .expect("read providers.json");
9761        assert!(
9762            !content.contains("api_key_encrypted"),
9763            "providers.json must not store provider ciphertext"
9764        );
9765        assert!(
9766            !content.contains("\"api_key\""),
9767            "providers.json should not store plaintext provider keys"
9768        );
9769
9770        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9771        let openai = loaded
9772            .providers
9773            .openai
9774            .as_ref()
9775            .expect("openai config should be present");
9776        assert_eq!(openai.api_key, "sk-test-provider-key");
9777
9778        drop(key_guard);
9779    }
9780
9781    #[test]
9782    fn config_save_persists_mcp_servers_in_mainstream_format() {
9783        let _lock = env_lock_acquire();
9784        let temp_home = TempHome::new();
9785
9786        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9787
9788        let mut env = std::collections::HashMap::new();
9789        env.insert("TOKEN".to_string(), "supersecret".to_string());
9790
9791        config.mcp.servers = vec![
9792            bamboo_domain::mcp_config::McpServerConfig {
9793                id: "stdio-secret".to_string(),
9794                name: None,
9795                enabled: true,
9796                transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9797                    bamboo_domain::mcp_config::StdioConfig {
9798                        command: "echo".to_string(),
9799                        args: vec![],
9800                        cwd: None,
9801                        env,
9802                        env_encrypted: std::collections::HashMap::new(),
9803                        env_credential_refs: std::collections::HashMap::new(),
9804                        startup_timeout_ms: 5000,
9805                    },
9806                ),
9807                request_timeout_ms: 5000,
9808                healthcheck_interval_ms: 1000,
9809                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9810                allowed_tools: vec![],
9811                denied_tools: vec![],
9812            },
9813            bamboo_domain::mcp_config::McpServerConfig {
9814                id: "http-secret".to_string(),
9815                name: None,
9816                enabled: true,
9817                transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9818                    bamboo_domain::mcp_config::StreamableHttpConfig {
9819                        url: "http://localhost:8080/http".to_string(),
9820                        headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9821                            name: "Authorization".to_string(),
9822                            value: "Bearer token123".to_string(),
9823                            value_encrypted: None,
9824                            credential_ref: None,
9825                        }],
9826                        connect_timeout_ms: 5000,
9827                    },
9828                ),
9829                request_timeout_ms: 5000,
9830                healthcheck_interval_ms: 1000,
9831                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9832                allowed_tools: vec![],
9833                denied_tools: vec![],
9834            },
9835        ];
9836
9837        config
9838            .save_to_dir(temp_home.path.clone())
9839            .expect("save should persist MCP servers");
9840
9841        let content =
9842            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9843        assert!(
9844            content.contains("\"mcpServers\""),
9845            "config.json should store MCP servers under the mainstream 'mcpServers' key"
9846        );
9847        assert!(
9848            content.contains("supersecret"),
9849            "config.json should persist MCP stdio env in mainstream format"
9850        );
9851        assert!(
9852            content.contains("Bearer token123"),
9853            "config.json should persist MCP Streamable HTTP headers in mainstream format"
9854        );
9855        assert!(
9856            !content.contains("\"env_encrypted\""),
9857            "config.json should not persist legacy env_encrypted fields"
9858        );
9859        assert!(
9860            !content.contains("\"value_encrypted\""),
9861            "config.json should not persist legacy value_encrypted fields"
9862        );
9863
9864        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9865        let stdio = loaded
9866            .mcp
9867            .servers
9868            .iter()
9869            .find(|s| s.id == "stdio-secret")
9870            .expect("stdio server should exist");
9871        match &stdio.transport {
9872            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9873                assert_eq!(
9874                    stdio.env.get("TOKEN").map(|s| s.as_str()),
9875                    Some("supersecret")
9876                );
9877            }
9878            _ => panic!("Expected stdio transport"),
9879        }
9880
9881        let http = loaded
9882            .mcp
9883            .servers
9884            .iter()
9885            .find(|s| s.id == "http-secret")
9886            .expect("http server should exist");
9887        match &http.transport {
9888            bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9889                assert_eq!(http.headers[0].value, "Bearer token123");
9890            }
9891            _ => panic!("Expected Streamable HTTP transport"),
9892        }
9893    }
9894
9895    #[test]
9896    fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9897        let _lock = env_lock_acquire();
9898        let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9899        let temp_home = TempHome::new();
9900        let env_plaintext = "mcp-root-env-plaintext-597";
9901        let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9902        let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9903        std::fs::write(
9904            temp_home.path.join("config.json"),
9905            serde_json::to_vec_pretty(&serde_json::json!({
9906                "features": {"provider_model_ref": true},
9907                "mcpServers": {
9908                    "stdio-root": {
9909                        "command": "unused-disabled-command",
9910                        "env": {"TOKEN": env_plaintext},
9911                        "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9912                        "env_credential_refs": {
9913                            "TOKEN": "mcp.stdio-root.env_TOKEN"
9914                        }
9915                    },
9916                    "http-root": {
9917                        "url": "https://example.test/mcp",
9918                        "transport_kind": "streamable_http",
9919                        "headers": {"Authorization": header_plaintext},
9920                        "header_credential_refs": {
9921                            "Authorization": "mcp.http-root.header_Authorization"
9922                        }
9923                    }
9924                }
9925            }))
9926            .unwrap(),
9927        )
9928        .unwrap();
9929        std::fs::write(
9930            temp_home.path.join("mcp.json"),
9931            serde_json::to_vec_pretty(&serde_json::json!({
9932                "schema_version": 1,
9933                "revision": 7,
9934                "data": {
9935                    "stdio-root": {
9936                        "command": "unused-disabled-command",
9937                        "enabled": false,
9938                        "env_encrypted": {"TOKEN": legacy_ciphertext},
9939                        "request_timeout_ms": 100,
9940                        "healthcheck_interval_ms": 100
9941                    },
9942                    "http-root": {
9943                        "url": "https://example.test/mcp",
9944                        "transport_kind": "streamable_http",
9945                        "enabled": false,
9946                        "headers": {"Authorization": header_plaintext},
9947                        "request_timeout_ms": 100,
9948                        "healthcheck_interval_ms": 100
9949                    }
9950                }
9951            }))
9952            .unwrap(),
9953        )
9954        .unwrap();
9955
9956        crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9957        let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9958            temp_home.path.join("mcp.json"),
9959            1,
9960        )
9961        .load()
9962        .unwrap()
9963        .unwrap();
9964        let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9965        config.mcp = stored.data;
9966        config
9967            .hydrate_mcp_credentials_from_store(&temp_home.path)
9968            .unwrap();
9969
9970        let public = serde_json::to_value(&config.mcp).unwrap();
9971        let rendered_public = public.to_string();
9972        assert!(rendered_public.contains(env_plaintext));
9973        assert!(rendered_public.contains(header_plaintext));
9974        let compatible: bamboo_domain::mcp_config::McpConfig =
9975            serde_json::from_value(public).unwrap();
9976        assert_eq!(compatible.servers.len(), 2);
9977
9978        config.features.provider_model_ref = !config.features.provider_model_ref;
9979        config.save_to_dir(temp_home.path.clone()).unwrap();
9980        config.features.provider_model_ref = !config.features.provider_model_ref;
9981        config.save_to_dir(temp_home.path.clone()).unwrap();
9982
9983        for entry in std::fs::read_dir(&temp_home.path)
9984            .unwrap()
9985            .filter_map(Result::ok)
9986        {
9987            let name = entry.file_name().to_string_lossy().to_string();
9988            if name == "credentials.json" || entry.path().is_dir() {
9989                continue;
9990            }
9991            if name == "config.json"
9992                || name == "mcp.json"
9993                || name.contains(".bak")
9994                || name.starts_with("config-credential-migration")
9995            {
9996                let bytes = std::fs::read(entry.path()).unwrap();
9997                let content = String::from_utf8_lossy(&bytes);
9998                assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9999                assert!(
10000                    !content.contains(header_plaintext),
10001                    "secret leaked to {name}"
10002                );
10003                assert!(
10004                    !content.contains(&legacy_ciphertext),
10005                    "legacy ciphertext leaked to {name}"
10006                );
10007            }
10008        }
10009    }
10010
10011    // ── Env vars lifecycle tests ──────────────────────────────
10012
10013    #[test]
10014    fn env_vars_as_map_includes_only_non_empty_values() {
10015        let mut config = Config::default();
10016        config.env_vars.extend([
10017            EnvVarEntry {
10018                name: "A".to_string(),
10019                value: "val_a".to_string(),
10020                secret: false,
10021                value_encrypted: None,
10022                credential_ref: None,
10023                configured: true,
10024                description: None,
10025            },
10026            EnvVarEntry {
10027                name: "B".to_string(),
10028                value: "".to_string(), // empty → should be excluded
10029                secret: true,
10030                value_encrypted: None,
10031                credential_ref: None,
10032                configured: false,
10033                description: None,
10034            },
10035            EnvVarEntry {
10036                name: "C".to_string(),
10037                value: "  ".to_string(), // whitespace-only → excluded
10038                secret: false,
10039                value_encrypted: None,
10040                credential_ref: None,
10041                configured: true,
10042                description: None,
10043            },
10044            EnvVarEntry {
10045                name: "D".to_string(),
10046                value: "val_d".to_string(),
10047                secret: true,
10048                value_encrypted: Some("enc".to_string()),
10049                credential_ref: None,
10050                configured: true,
10051                description: Some("desc".to_string()),
10052            },
10053        ]);
10054
10055        let map = config.env_vars_as_map();
10056        assert_eq!(map.len(), 2);
10057        assert_eq!(map.get("A"), Some(&"val_a".to_string()));
10058        assert_eq!(map.get("D"), Some(&"val_d".to_string()));
10059        assert!(!map.contains_key("B"));
10060        assert!(!map.contains_key("C"));
10061    }
10062
10063    #[test]
10064    fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
10065        let mut config = Config::default();
10066        config.env_vars.extend([
10067            EnvVarEntry {
10068                name: "PLAIN".to_string(),
10069                value: "visible".to_string(),
10070                secret: false,
10071                value_encrypted: None,
10072                credential_ref: None,
10073                configured: true,
10074                description: None,
10075            },
10076            EnvVarEntry {
10077                name: "SECRET".to_string(),
10078                value: "hidden_value".to_string(),
10079                secret: true,
10080                value_encrypted: Some("enc_data".to_string()),
10081                credential_ref: None,
10082                configured: true,
10083                description: None,
10084            },
10085        ]);
10086
10087        config.sanitize_env_vars_for_disk();
10088
10089        assert_eq!(config.env_vars[0].value, "visible"); // plain kept
10090        assert_eq!(config.env_vars[1].value, ""); // secret cleared
10091    }
10092
10093    #[test]
10094    fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
10095        let mut config = Config::default();
10096        config.env_vars.extend([
10097            EnvVarEntry {
10098                name: "OPEN".to_string(),
10099                value: "val".to_string(),
10100                secret: false,
10101                value_encrypted: None,
10102                credential_ref: None,
10103                configured: true,
10104                description: None,
10105            },
10106            EnvVarEntry {
10107                name: "HIDDEN".to_string(),
10108                value: "real_secret".to_string(),
10109                secret: true,
10110                value_encrypted: Some("enc".to_string()),
10111                credential_ref: None,
10112                configured: true,
10113                description: None,
10114            },
10115        ]);
10116
10117        config.sanitize_env_vars_for_disk();
10118
10119        // Plain value untouched
10120        assert_eq!(config.env_vars[0].value, "val");
10121        // Secret plaintext and legacy ciphertext are removed.
10122        assert_eq!(config.env_vars[1].value, "");
10123        assert!(config.env_vars[1].value_encrypted.is_none());
10124    }
10125
10126    #[test]
10127    fn legacy_env_ciphertext_is_read_but_never_serialized() {
10128        let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
10129        let mut config: Config = serde_json::from_value(serde_json::json!({
10130            "env_vars": [{
10131                "name": "TOKEN",
10132                "secret": true,
10133                "value_encrypted": ciphertext
10134            }]
10135        }))
10136        .unwrap();
10137        config.hydrate_env_vars_from_encrypted();
10138        assert_eq!(config.env_vars[0].value, "my-secret-token");
10139        let serialized = serde_json::to_value(&config).unwrap();
10140        assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
10141    }
10142
10143    #[test]
10144    fn configured_env_ref_missing_from_store_fails_closed() {
10145        let dir = tempfile::tempdir().unwrap();
10146        let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
10147        let mut config = Config::default();
10148        config.env_vars.push(EnvVarEntry {
10149            name: "TOKEN".to_string(),
10150            value: String::new(),
10151            secret: true,
10152            value_encrypted: None,
10153            credential_ref: Some(reference),
10154            configured: true,
10155            description: None,
10156        });
10157        let error = config
10158            .hydrate_env_var_credentials_from_store(dir.path())
10159            .unwrap_err();
10160        assert!(error
10161            .to_string()
10162            .contains("referenced env credential is unavailable"));
10163        assert!(config.env_vars[0].configured);
10164        assert!(config.env_vars[0].value.is_empty());
10165    }
10166
10167    #[test]
10168    fn configured_notification_ref_missing_from_store_fails_closed() {
10169        let dir = tempfile::tempdir().unwrap();
10170        let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
10171        let mut config = Config::default();
10172        config.notifications.ntfy.credential_ref = Some(reference);
10173        config.notifications.ntfy.configured = true;
10174
10175        let error = config
10176            .hydrate_notification_credentials_from_store(dir.path())
10177            .unwrap_err();
10178
10179        assert!(error
10180            .to_string()
10181            .contains("referenced ntfy credential is unavailable"));
10182        assert!(config.notifications.ntfy.configured);
10183        assert!(config.notifications.ntfy.token.is_none());
10184    }
10185
10186    #[test]
10187    fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
10188        let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
10189        let dir = tempfile::tempdir().unwrap();
10190        let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
10191        crate::CredentialStore::open(dir.path())
10192            .replace(
10193                reference.clone(),
10194                "exclusive-notification-secret",
10195                crate::CredentialSource::User,
10196                0,
10197            )
10198            .unwrap();
10199
10200        let mut config = Config::default();
10201        config.notifications.ntfy.credential_ref = Some(reference.clone());
10202        config.notifications.ntfy.configured = true;
10203        config.proxy_auth_credential_ref = Some(reference.clone());
10204        let error = config
10205            .hydrate_notification_credentials_from_store(dir.path())
10206            .unwrap_err();
10207        let rendered = error.to_string();
10208        assert!(rendered
10209            .contains("notification credential reference is shared by another config consumer"));
10210        assert!(!rendered.contains(reference.as_str()));
10211        assert!(config.notifications.ntfy.token.is_none());
10212
10213        config.proxy_auth_credential_ref = None;
10214        config
10215            .hydrate_notification_credentials_from_store(dir.path())
10216            .unwrap();
10217        assert_eq!(
10218            config.notifications.ntfy.token.as_deref(),
10219            Some("exclusive-notification-secret")
10220        );
10221    }
10222
10223    #[test]
10224    fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
10225        let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
10226        let dir = tempfile::tempdir().unwrap();
10227        let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
10228        let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
10229        let bytes = serde_json::to_vec_pretty(&serde_json::json!({
10230            "notifications": {
10231                "ntfy": { "enabled": true, "token_encrypted": ntfy },
10232                "bark": { "enabled": true, "device_key_encrypted": bark }
10233            }
10234        }))
10235        .unwrap();
10236        std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
10237        std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
10238
10239        let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
10240
10241        assert!(loaded.notifications.ntfy.token.is_none());
10242        assert!(loaded.notifications.bark.device_key.is_none());
10243        assert_eq!(
10244            std::fs::read(dir.path().join("config.json")).unwrap(),
10245            bytes
10246        );
10247    }
10248
10249    #[test]
10250    fn publish_and_current_env_vars_round_trip() {
10251        // `publish_env_vars` REPLACES the process-global env-vars cache
10252        // wholesale, so every test that touches that cache must hold the
10253        // crate-wide env lock. This test didn't (issue #486): running
10254        // concurrently with a lock-holding cache test (e.g.
10255        // `from_data_dir_without_publish_does_not_clobber_global_cache`) it
10256        // wiped that test's just-seeded marker out of the cache mid-assert —
10257        // and its own 10x retry loop below was itself a symptom of losing
10258        // the same race in the other direction. With the lock held, one
10259        // publish is deterministic.
10260        let _lock = crate::test_support::env_cache_lock_acquire();
10261        let mut config = Config::default();
10262        config.env_vars.extend([EnvVarEntry {
10263            name: "TEST_PUBLISH".to_string(),
10264            value: "pub_value".to_string(),
10265            secret: false,
10266            value_encrypted: None,
10267            credential_ref: None,
10268            configured: true,
10269            description: None,
10270        }]);
10271
10272        config.publish_env_vars();
10273        assert_eq!(
10274            Config::current_env_vars()
10275                .get("TEST_PUBLISH")
10276                .map(String::as_str),
10277            Some("pub_value")
10278        );
10279    }
10280
10281    #[test]
10282    fn broker_token_round_trips_encrypt_sanitize_hydrate() {
10283        let mut config = Config::default();
10284        config.subagents.broker = Some(BrokerClientConfig {
10285            endpoint: "ws://127.0.0.1:9600".to_string(),
10286            token: "super-secret-token".to_string(),
10287            token_encrypted: None,
10288            credential_ref: None,
10289            configured: false,
10290        });
10291
10292        // Persist path: encrypt then sanitize (what save_to_dir does).
10293        config.refresh_broker_token_encrypted().unwrap();
10294        config.sanitize_broker_token_for_disk();
10295        let broker = config.subagents.broker.as_ref().unwrap();
10296        assert!(broker.token.is_empty(), "plaintext cleared for disk");
10297        assert!(broker.token_encrypted.is_some(), "ciphertext stored");
10298        assert_ne!(
10299            broker.token_encrypted.as_deref(),
10300            Some("super-secret-token")
10301        );
10302
10303        // Load path: hydrate restores plaintext.
10304        config.hydrate_broker_token_from_encrypted();
10305        assert_eq!(
10306            config.subagents.broker.as_ref().unwrap().token,
10307            "super-secret-token"
10308        );
10309    }
10310
10311    #[test]
10312    fn broker_token_empty_refresh_preserves_ciphertext() {
10313        // A redacted round-trip (token empty) must not wipe the stored ciphertext.
10314        let mut config = Config::default();
10315        config.subagents.broker = Some(BrokerClientConfig {
10316            endpoint: "ws://h:9600".to_string(),
10317            token: String::new(),
10318            token_encrypted: Some("existing-cipher".to_string()),
10319            credential_ref: None,
10320            configured: false,
10321        });
10322        config.refresh_broker_token_encrypted().unwrap();
10323        assert_eq!(
10324            config
10325                .subagents
10326                .broker
10327                .as_ref()
10328                .unwrap()
10329                .token_encrypted
10330                .as_deref(),
10331            Some("existing-cipher"),
10332        );
10333    }
10334
10335    #[test]
10336    fn notifications_config_defaults_when_key_missing() {
10337        // Additive/back-compat: an absent `notifications` key must deserialize
10338        // to the built-in defaults (desktop auto, ntfy/bark disabled).
10339        let config: Config = serde_json::from_str("{}").expect("empty object parses");
10340        assert_eq!(config.notifications, NotificationsConfig::default());
10341        assert_eq!(config.notifications.desktop.enabled, None);
10342        assert!(!config.notifications.ntfy.enabled);
10343        assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10344        assert_eq!(config.notifications.ntfy.token, None);
10345        assert!(!config.notifications.bark.enabled);
10346        assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10347        assert_eq!(config.notifications.bark.device_key, None);
10348    }
10349
10350    #[test]
10351    fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10352        let mut config = Config::default();
10353        config.notifications.ntfy = NtfyChannelConfig {
10354            enabled: true,
10355            base_url: "https://ntfy.sh".to_string(),
10356            topic: "bamboo-alerts".to_string(),
10357            token: Some("tk_super_secret".to_string()),
10358            token_encrypted: None,
10359            credential_ref: None,
10360            configured: false,
10361        };
10362
10363        // Legacy compatibility path: retain a readable ciphertext until the
10364        // credential migration moves the secret into the isolated store.
10365        config.refresh_notifications_encrypted().unwrap();
10366        assert!(config.notifications.ntfy.token_encrypted.is_some());
10367        assert_ne!(
10368            config.notifications.ntfy.token_encrypted.as_deref(),
10369            Some("tk_super_secret")
10370        );
10371
10372        // Neither plaintext nor legacy ciphertext is serializable.
10373        let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10374        assert!(
10375            !json.contains("tk_super_secret"),
10376            "plaintext token must never be serialized"
10377        );
10378        assert!(!json.contains("token_encrypted"));
10379
10380        // Legacy load path restores plaintext for migration.
10381        config.notifications.ntfy.token = None;
10382        config.hydrate_notifications_from_encrypted();
10383        assert_eq!(
10384            config.notifications.ntfy.token.as_deref(),
10385            Some("tk_super_secret")
10386        );
10387    }
10388
10389    #[test]
10390    fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10391        let mut config = Config::default();
10392        config.notifications.bark = BarkChannelConfig {
10393            enabled: true,
10394            base_url: "https://api.day.app".to_string(),
10395            device_key: Some("dk_super_secret".to_string()),
10396            device_key_encrypted: None,
10397            credential_ref: None,
10398            configured: false,
10399        };
10400
10401        config.refresh_notifications_encrypted().unwrap();
10402        assert!(config.notifications.bark.device_key_encrypted.is_some());
10403        assert_ne!(
10404            config.notifications.bark.device_key_encrypted.as_deref(),
10405            Some("dk_super_secret")
10406        );
10407
10408        let json = serde_json::to_string(&config.notifications.bark).unwrap();
10409        assert!(
10410            !json.contains("dk_super_secret"),
10411            "plaintext device key must never be serialized"
10412        );
10413        assert!(!json.contains("device_key_encrypted"));
10414
10415        config.notifications.bark.device_key = None;
10416        config.hydrate_notifications_from_encrypted();
10417        assert_eq!(
10418            config.notifications.bark.device_key.as_deref(),
10419            Some("dk_super_secret")
10420        );
10421    }
10422
10423    #[test]
10424    fn notification_secrets_empty_refresh_preserves_ciphertext() {
10425        // The legacy compatibility helper retains already-loaded ciphertext;
10426        // ordinary persistence sanitizes it before writing.
10427        let mut config = Config::default();
10428        config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10429        config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10430
10431        config.refresh_notifications_encrypted().unwrap();
10432
10433        assert_eq!(
10434            config.notifications.ntfy.token_encrypted.as_deref(),
10435            Some("existing-ntfy-cipher")
10436        );
10437        assert_eq!(
10438            config.notifications.bark.device_key_encrypted.as_deref(),
10439            Some("existing-bark-cipher")
10440        );
10441    }
10442
10443    #[test]
10444    fn hydrate_skips_non_secret_entries() {
10445        let mut config = Config::default();
10446        config.env_vars.extend([EnvVarEntry {
10447            name: "PLAIN".to_string(),
10448            value: "original".to_string(),
10449            secret: false,
10450            value_encrypted: Some("should-be-ignored".to_string()),
10451            credential_ref: None,
10452            configured: true,
10453            description: None,
10454        }]);
10455
10456        config.hydrate_env_vars_from_encrypted();
10457        // Non-secret entry should keep its original value
10458        assert_eq!(config.env_vars[0].value, "original");
10459    }
10460
10461    #[test]
10462    fn default_config_has_empty_env_vars() {
10463        // `Config::default()` is a pure in-memory constructor (no disk read, no
10464        // env overrides), so this is independent of the developer's
10465        // `~/.bamboo/config.json` — no temp-dir isolation needed. Directly
10466        // asserts the #38 invariant that default() does not touch the filesystem.
10467        assert!(Config::default().env_vars.is_empty());
10468    }
10469
10470    #[test]
10471    fn serde_round_trip_with_env_vars() {
10472        let mut config = Config::default();
10473        config.env_vars.extend([
10474            EnvVarEntry {
10475                name: "KEY1".to_string(),
10476                value: "val1".to_string(),
10477                secret: false,
10478                value_encrypted: None,
10479                credential_ref: None,
10480                configured: true,
10481                description: Some("First key".to_string()),
10482            },
10483            EnvVarEntry {
10484                name: "KEY2".to_string(),
10485                value: "".to_string(), // on-disk secret has no plaintext
10486                secret: true,
10487                value_encrypted: Some("enc123".to_string()),
10488                credential_ref: None,
10489                configured: true,
10490                description: None,
10491            },
10492        ]);
10493
10494        let json = serde_json::to_string(&config).unwrap();
10495        let restored: Config = serde_json::from_str(&json).unwrap();
10496
10497        assert_eq!(restored.env_vars.len(), 2);
10498        assert_eq!(restored.env_vars[0].name, "KEY1");
10499        assert_eq!(restored.env_vars[0].value, "val1");
10500        assert!(!restored.env_vars[0].secret);
10501        assert_eq!(restored.env_vars[1].name, "KEY2");
10502        assert!(restored.env_vars[1].secret);
10503        assert!(restored.env_vars[1].value_encrypted.is_none());
10504    }
10505
10506    // ---- defaults.* model resolution tests ----
10507
10508    #[test]
10509    // fields set conditionally below
10510    #[allow(clippy::field_reassign_with_default)]
10511    fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10512        let mut config = Config::default();
10513        config.provider = "openai".to_string();
10514        config.providers.openai = Some(OpenAIConfig {
10515            api_key: "test".to_string(),
10516            api_key_encrypted: None,
10517            credential_ref: None,
10518            base_url: None,
10519            model: Some("legacy-gpt-4o".to_string()),
10520            fast_model: None,
10521            vision_model: None,
10522            reasoning_effort: None,
10523            responses_only_models: vec![],
10524            request_overrides: None,
10525            extra: Default::default(),
10526            api_key_from_env: false,
10527        });
10528        config.features.provider_model_ref = true;
10529        config.defaults = Some(DefaultsConfig {
10530            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10531            fast: None,
10532            task_summary: None,
10533            vision: None,
10534            memory_background: None,
10535            planning: None,
10536            search: None,
10537            code_review: None,
10538            work_review: None,
10539            sub_agent: None,
10540            subagent_models: Default::default(),
10541        });
10542
10543        assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10544    }
10545
10546    #[test]
10547    // fields set conditionally below
10548    #[allow(clippy::field_reassign_with_default)]
10549    fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10550        let mut config = Config::default();
10551        config.provider = "openai".to_string();
10552        config.providers.openai = Some(OpenAIConfig {
10553            api_key: "test".to_string(),
10554            api_key_encrypted: None,
10555            credential_ref: None,
10556            base_url: None,
10557            model: Some("legacy-gpt-4o".to_string()),
10558            fast_model: None,
10559            vision_model: None,
10560            reasoning_effort: None,
10561            responses_only_models: vec![],
10562            request_overrides: None,
10563            extra: Default::default(),
10564            api_key_from_env: false,
10565        });
10566        config.features.provider_model_ref = false;
10567        config.defaults = Some(DefaultsConfig {
10568            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10569            fast: None,
10570            task_summary: None,
10571            vision: None,
10572            memory_background: None,
10573            planning: None,
10574            search: None,
10575            code_review: None,
10576            work_review: None,
10577            sub_agent: None,
10578            subagent_models: Default::default(),
10579        });
10580
10581        assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10582    }
10583
10584    #[test]
10585    // fields set conditionally below
10586    #[allow(clippy::field_reassign_with_default)]
10587    fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10588        let mut config = Config::default();
10589        config.provider = "openai".to_string();
10590        config.providers.openai = Some(OpenAIConfig {
10591            api_key: "test".to_string(),
10592            api_key_encrypted: None,
10593            credential_ref: None,
10594            base_url: None,
10595            model: Some("gpt-4o".to_string()),
10596            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10597            vision_model: None,
10598            reasoning_effort: None,
10599            responses_only_models: vec![],
10600            request_overrides: None,
10601            extra: Default::default(),
10602            api_key_from_env: false,
10603        });
10604        config.features.provider_model_ref = true;
10605        config.defaults = Some(DefaultsConfig {
10606            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10607            fast: Some(bamboo_domain::ProviderModelRef::new(
10608                "anthropic",
10609                "claude-3-5-haiku",
10610            )),
10611            task_summary: None,
10612            vision: None,
10613            memory_background: None,
10614            planning: None,
10615            search: None,
10616            code_review: None,
10617            work_review: None,
10618            sub_agent: None,
10619            subagent_models: Default::default(),
10620        });
10621
10622        assert_eq!(
10623            config.get_fast_model(),
10624            Some("claude-3-5-haiku".to_string())
10625        );
10626    }
10627
10628    #[test]
10629    // fields set conditionally below
10630    #[allow(clippy::field_reassign_with_default)]
10631    fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10632        let mut config = Config::default();
10633        config.provider = "openai".to_string();
10634        config.providers.openai = Some(OpenAIConfig {
10635            api_key: "test".to_string(),
10636            api_key_encrypted: None,
10637            credential_ref: None,
10638            base_url: None,
10639            model: Some("gpt-4o".to_string()),
10640            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10641            vision_model: None,
10642            reasoning_effort: None,
10643            responses_only_models: vec![],
10644            request_overrides: None,
10645            extra: Default::default(),
10646            api_key_from_env: false,
10647        });
10648        config.features.provider_model_ref = false;
10649        config.defaults = Some(DefaultsConfig {
10650            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10651            fast: Some(bamboo_domain::ProviderModelRef::new(
10652                "anthropic",
10653                "claude-3-5-haiku",
10654            )),
10655            task_summary: None,
10656            vision: None,
10657            memory_background: None,
10658            planning: None,
10659            search: None,
10660            code_review: None,
10661            work_review: None,
10662            sub_agent: None,
10663            subagent_models: Default::default(),
10664        });
10665
10666        assert_eq!(
10667            config.get_fast_model(),
10668            Some("legacy-gpt-4o-mini".to_string())
10669        );
10670    }
10671
10672    #[test]
10673    // fields set conditionally below
10674    #[allow(clippy::field_reassign_with_default)]
10675    fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10676        let mut config = Config::default();
10677        config.provider = "openai".to_string();
10678        config.features.provider_model_ref = true;
10679        config.defaults = Some(DefaultsConfig {
10680            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10681            fast: None,
10682            task_summary: None,
10683            vision: None,
10684            memory_background: None,
10685            planning: None,
10686            search: None,
10687            code_review: None,
10688            work_review: None,
10689            sub_agent: None,
10690            subagent_models: Default::default(),
10691        });
10692
10693        assert_eq!(
10694            config.get_fast_model(),
10695            Some("claude-3-7-sonnet".to_string())
10696        );
10697    }
10698
10699    #[test]
10700    // fields set conditionally below
10701    #[allow(clippy::field_reassign_with_default)]
10702    fn get_memory_background_model_prefers_defaults_memory_background() {
10703        let mut config = Config::default();
10704        config.provider = "openai".to_string();
10705        config.providers.openai = Some(OpenAIConfig {
10706            api_key: "test".to_string(),
10707            api_key_encrypted: None,
10708            credential_ref: None,
10709            base_url: None,
10710            model: Some("gpt-4o".to_string()),
10711            fast_model: Some("gpt-4o-mini".to_string()),
10712            vision_model: None,
10713            reasoning_effort: None,
10714            responses_only_models: vec![],
10715            request_overrides: None,
10716            extra: Default::default(),
10717            api_key_from_env: false,
10718        });
10719        config.features.provider_model_ref = true;
10720        config.defaults = Some(DefaultsConfig {
10721            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10722            fast: Some(bamboo_domain::ProviderModelRef::new(
10723                "openai",
10724                "gpt-4o-mini",
10725            )),
10726            task_summary: None,
10727            vision: None,
10728            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10729                "anthropic",
10730                "claude-3-5-haiku",
10731            )),
10732            planning: None,
10733            search: None,
10734            code_review: None,
10735            work_review: None,
10736            sub_agent: None,
10737            subagent_models: Default::default(),
10738        });
10739
10740        assert_eq!(
10741            config.get_memory_background_model(),
10742            Some("claude-3-5-haiku".to_string())
10743        );
10744    }
10745
10746    #[test]
10747    // fields set conditionally below
10748    #[allow(clippy::field_reassign_with_default)]
10749    fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10750        let mut config = Config::default();
10751        config.provider = "openai".to_string();
10752        config.features.provider_model_ref = true;
10753        config.defaults = Some(DefaultsConfig {
10754            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10755            fast: Some(bamboo_domain::ProviderModelRef::new(
10756                "anthropic",
10757                "claude-3-5-haiku",
10758            )),
10759            task_summary: None,
10760            vision: None,
10761            memory_background: None,
10762            planning: None,
10763            search: None,
10764            code_review: None,
10765            work_review: None,
10766            sub_agent: None,
10767            subagent_models: Default::default(),
10768        });
10769
10770        assert_eq!(
10771            config.get_memory_background_model(),
10772            Some("claude-3-5-haiku".to_string())
10773        );
10774    }
10775
10776    #[test]
10777    // fields set conditionally below
10778    #[allow(clippy::field_reassign_with_default)]
10779    fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10780        let mut config = Config::default();
10781        config.provider = "openai".to_string();
10782        config.providers.openai = Some(OpenAIConfig {
10783            api_key: "test".to_string(),
10784            api_key_encrypted: None,
10785            credential_ref: None,
10786            base_url: None,
10787            model: Some("gpt-4o".to_string()),
10788            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10789            vision_model: None,
10790            reasoning_effort: None,
10791            responses_only_models: vec![],
10792            request_overrides: None,
10793            extra: Default::default(),
10794            api_key_from_env: false,
10795        });
10796        config.features.provider_model_ref = false;
10797        config.defaults = Some(DefaultsConfig {
10798            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10799            fast: Some(bamboo_domain::ProviderModelRef::new(
10800                "anthropic",
10801                "claude-3-5-haiku",
10802            )),
10803            task_summary: None,
10804            vision: None,
10805            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10806                "anthropic",
10807                "claude-3-5-haiku",
10808            )),
10809            planning: None,
10810            search: None,
10811            code_review: None,
10812            work_review: None,
10813            sub_agent: None,
10814            subagent_models: Default::default(),
10815        });
10816
10817        assert_eq!(
10818            config.get_memory_background_model(),
10819            Some("legacy-gpt-4o-mini".to_string())
10820        );
10821    }
10822
10823    // -------------------------------------------------------------------
10824    // `is_host_trusted` — plugin source-trust host allowlist (component
10825    // matching, not raw string-prefix matching; see the function's own docs
10826    // for the bypasses this closes).
10827    // -------------------------------------------------------------------
10828
10829    #[test]
10830    fn is_host_trusted_requires_https_scheme() {
10831        let hosts = vec!["github.com/bigduu/".to_string()];
10832        assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10833        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10834    }
10835
10836    #[test]
10837    fn is_host_trusted_is_case_insensitive_on_both_sides() {
10838        // A lowercase URL host against a mixed-case config entry...
10839        let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10840        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10841        // ...and a mixed-case URL host against a lowercase config entry.
10842        let hosts = vec!["github.com/bigduu/".to_string()];
10843        assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10844    }
10845
10846    #[test]
10847    fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10848        let hosts = vec!["trusted.example.com".to_string()];
10849        assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10850        // Both demonstrated bypasses of a raw string-prefix match: gluing a
10851        // longer attacker-controlled label onto the trusted host, with or
10852        // without a separating dot.
10853        assert!(!is_host_trusted(
10854            "https://trusted.example.com.evil.com/x",
10855            &hosts
10856        ));
10857        assert!(!is_host_trusted(
10858            "https://trusted.example.comevil.com/x",
10859            &hosts
10860        ));
10861    }
10862
10863    #[test]
10864    fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10865        // No trailing slash on the config entry's path component.
10866        let hosts = vec!["github.com/bigduu/".to_string()];
10867        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10868        assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10869    }
10870
10871    #[test]
10872    fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10873        let hosts = vec!["example.com".to_string()];
10874        assert!(is_host_trusted("https://example.com/", &hosts));
10875        assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10876        // Still only that exact host — a bare-host entry must not become a
10877        // blanket "any host containing this string" match.
10878        assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10879        assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10880    }
10881
10882    #[test]
10883    fn is_host_trusted_uses_the_real_host_not_userinfo() {
10884        let hosts = vec!["github.com/bigduu/".to_string()];
10885        // `user@host` userinfo does not change the actual host.
10886        assert!(is_host_trusted(
10887            "https://someuser@github.com/bigduu/x",
10888            &hosts
10889        ));
10890        // A decoy host placed in the userinfo position must not be mistaken
10891        // for the real host — the real host here is `evil.com`.
10892        assert!(!is_host_trusted(
10893            "https://github.com@evil.com/bigduu/",
10894            &hosts
10895        ));
10896    }
10897
10898    #[test]
10899    fn is_host_trusted_ignores_an_explicit_port() {
10900        let hosts = vec!["github.com/bigduu/".to_string()];
10901        assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10902    }
10903
10904    #[test]
10905    fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10906        let hosts = vec!["github.com/bigduu/".to_string()];
10907        assert!(!is_host_trusted("not a url at all", &hosts));
10908        assert!(!is_host_trusted("", &hosts));
10909        assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); // no scheme
10910    }
10911
10912    #[test]
10913    fn is_host_trusted_normalizes_dot_segments_before_matching() {
10914        let hosts = vec!["github.com/bigduu/".to_string()];
10915        // `Url::parse` resolves `..` segments before `path()` is ever
10916        // consulted, so this cannot be used to escape the trusted prefix.
10917        assert!(!is_host_trusted(
10918            "https://github.com/bigduu/../evil/x",
10919            &hosts
10920        ));
10921        // A `..` that stays under the trusted prefix once resolved is fine.
10922        assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10923    }
10924
10925    #[test]
10926    fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10927        let mut config = Config::default();
10928        config.plugin_trust.trusted_hosts = vec![
10929            "  GitHub.com/BigDuu/ ".to_string(),
10930            "".to_string(),
10931            "   ".to_string(),
10932            "Example.COM".to_string(),
10933        ];
10934        config.normalize_plugin_trust_settings();
10935        assert_eq!(
10936            config.plugin_trust.trusted_hosts,
10937            vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10938        );
10939    }
10940
10941    // -----------------------------------------------------------------
10942    // `plugin_trust.enforcement` — the persistent, config-level form of the
10943    // `--insecure` escape hatch.
10944    // -----------------------------------------------------------------
10945
10946    #[test]
10947    fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10948        // A fresh `Config::default()` (nothing on disk at all).
10949        let config = Config::default();
10950        assert_eq!(
10951            config.plugin_trust.enforcement,
10952            PluginTrustEnforcement::Strict
10953        );
10954        assert!(!config.plugin_trust.enforcement_is_off());
10955
10956        // A `plugin_trust` object present in JSON but with NO `enforcement`
10957        // key at all (e.g. a config.json written before this field existed)
10958        // must ALSO deserialize to Strict, not fail or silently do something
10959        // else — additive/back-compat, matching `trusted_hosts`/
10960        // `trusted_keys`'s own `#[serde(default = ...)]` behavior.
10961        let json = serde_json::json!({
10962            "trusted_hosts": ["example.com"],
10963            "trusted_keys": [],
10964        });
10965        let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10966        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10967    }
10968
10969    #[test]
10970    fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10971        for raw in ["off", "OFF", "Off", " off "] {
10972            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10973                "enforcement": raw,
10974            }))
10975            .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10976            assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10977            assert!(trust.enforcement_is_off());
10978        }
10979        for raw in ["strict", "STRICT", " Strict "] {
10980            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10981                "enforcement": raw,
10982            }))
10983            .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10984            assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10985        }
10986
10987        let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10988            "enforcement": "nonsense",
10989        }))
10990        .expect_err("an unrecognized string must be rejected, not silently default");
10991        assert!(err.to_string().contains("nonsense"));
10992    }
10993
10994    #[test]
10995    fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10996        // A hand-edited config.json using a plain bool reads naturally: is
10997        // enforcement ON (`true`) or OFF (`false`)?
10998        let trust: PluginTrustConfig =
10999            serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
11000        assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
11001
11002        let trust: PluginTrustConfig =
11003            serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
11004        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
11005    }
11006
11007    #[test]
11008    fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
11009        // Regardless of which accepted input form produced it, the
11010        // in-memory value always serializes back out as the canonical
11011        // string — this is what the dot-path `config set` setter's
11012        // round-trip check relies on (see `dot_path.rs`'s module docs).
11013        let trust = PluginTrustConfig {
11014            enforcement: PluginTrustEnforcement::Off,
11015            ..PluginTrustConfig::default()
11016        };
11017        let json = serde_json::to_value(&trust).unwrap();
11018        assert_eq!(json["enforcement"], "off");
11019
11020        let trust = PluginTrustConfig {
11021            enforcement: PluginTrustEnforcement::Strict,
11022            ..PluginTrustConfig::default()
11023        };
11024        let json = serde_json::to_value(&trust).unwrap();
11025        assert_eq!(json["enforcement"], "strict");
11026    }
11027
11028    #[test]
11029    fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
11030        // `normalize_plugin_trust_settings` only touches `trusted_hosts` —
11031        // confirm it's a true no-op on `enforcement` either way.
11032        let mut config = Config::default();
11033        config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
11034        config.normalize_plugin_trust_settings();
11035        assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
11036    }
11037
11038    #[test]
11039    fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
11040        // Confirms the dot-path `bamboo config set plugin_trust.enforcement
11041        // off` path actually works end to end through
11042        // `crate::dot_path::apply_dot_path_set` (the generic JSON-patch
11043        // setter), not just direct field assignment.
11044        let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
11045            "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
11046        )));
11047        assert_eq!(
11048            config.plugin_trust.enforcement,
11049            PluginTrustEnforcement::Strict
11050        );
11051
11052        let outcome = crate::dot_path::apply_dot_path_set(
11053            &config,
11054            "plugin_trust.enforcement",
11055            crate::dot_path::parse_cli_value("off"),
11056        )
11057        .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
11058        assert_eq!(
11059            outcome.config.plugin_trust.enforcement,
11060            PluginTrustEnforcement::Off
11061        );
11062
11063        // And back to strict.
11064        let outcome = crate::dot_path::apply_dot_path_set(
11065            &outcome.config,
11066            "plugin_trust.enforcement",
11067            crate::dot_path::parse_cli_value("strict"),
11068        )
11069        .expect("setting it back to strict should also round-trip");
11070        assert_eq!(
11071            outcome.config.plugin_trust.enforcement,
11072            PluginTrustEnforcement::Strict
11073        );
11074    }
11075}
11076
11077#[cfg(test)]
11078mod final_evidence_feature_tests {
11079    use super::FeatureFlags;
11080
11081    #[test]
11082    fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
11083        let existing: FeatureFlags =
11084            serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
11085        assert!(!existing.final_evidence_check);
11086        let enabled: FeatureFlags =
11087            serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
11088        let restored: FeatureFlags =
11089            serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
11090        assert!(restored.final_evidence_check);
11091        assert!(!FeatureFlags::default().final_evidence_check);
11092    }
11093}