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};
63use bamboo_domain::poison::PoisonRecover;
64use serde::{Deserialize, Serialize};
65use serde_json::Value;
66use std::collections::{BTreeMap, BTreeSet, HashMap};
67use std::path::{Path, PathBuf};
68use std::sync::{OnceLock, RwLock};
69
70use crate::keyword_masking::KeywordMaskingConfig;
71use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
72use bamboo_domain::normalize_tool_ref;
73use bamboo_domain::ReasoningEffort;
74
75/// Minimum accepted watchdog deadline. Zero would turn scheduling jitter into
76/// an immediate stream failure, so it is rejected at the configuration boundary.
77pub const MIN_STREAM_TIMEOUT_SECS: u64 = 1;
78/// Maximum accepted watchdog deadline. This keeps a typo from disabling hung
79/// stream detection for days while still allowing operators to accommodate
80/// exceptionally slow reasoning models.
81pub const MAX_STREAM_TIMEOUT_SECS: u64 = 86_400;
82
83fn default_transport_idle_timeout_secs() -> u64 {
84    120
85}
86
87fn default_first_semantic_timeout_secs() -> u64 {
88    600
89}
90
91fn default_semantic_idle_timeout_secs() -> u64 {
92    600
93}
94
95fn deserialize_stream_timeout_secs<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error>
96where
97    D: serde::Deserializer<'de>,
98{
99    use serde::de::Error as _;
100
101    let value = u64::deserialize(deserializer)?;
102    if (MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
103        Ok(value)
104    } else {
105        Err(D::Error::custom(format!(
106            "stream timeout must be between {MIN_STREAM_TIMEOUT_SECS} and \
107             {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
108        )))
109    }
110}
111
112/// LLM stream watchdog policy shared by the main response path and auxiliary
113/// silent consumers.
114///
115/// Transport and semantic progress are deliberately separate. Valid SSE
116/// lifecycle/keepalive frames refresh `transport_idle_timeout_secs` without
117/// hiding a model that never produces semantic output. The first semantic
118/// deadline starts at request dispatch; after the first token/reasoning/tool
119/// delta, `semantic_idle_timeout_secs` becomes the semantic deadline.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(default)]
122pub struct StreamTimeoutConfig {
123    /// Maximum gap between valid transport frames, including SSE keepalives.
124    #[serde(
125        default = "default_transport_idle_timeout_secs",
126        deserialize_with = "deserialize_stream_timeout_secs"
127    )]
128    pub transport_idle_timeout_secs: u64,
129    /// Maximum time from request dispatch to the first semantic chunk.
130    #[serde(
131        default = "default_first_semantic_timeout_secs",
132        deserialize_with = "deserialize_stream_timeout_secs"
133    )]
134    pub first_semantic_timeout_secs: u64,
135    /// Maximum gap between semantic chunks after semantic output has started.
136    #[serde(
137        default = "default_semantic_idle_timeout_secs",
138        deserialize_with = "deserialize_stream_timeout_secs"
139    )]
140    pub semantic_idle_timeout_secs: u64,
141}
142
143impl Default for StreamTimeoutConfig {
144    fn default() -> Self {
145        Self {
146            transport_idle_timeout_secs: default_transport_idle_timeout_secs(),
147            first_semantic_timeout_secs: default_first_semantic_timeout_secs(),
148            semantic_idle_timeout_secs: default_semantic_idle_timeout_secs(),
149        }
150    }
151}
152
153impl StreamTimeoutConfig {
154    /// Validate values created programmatically (serde performs the same check
155    /// while loading `config.json`).
156    pub fn validate(&self) -> std::result::Result<(), String> {
157        for (name, value) in [
158            (
159                "transport_idle_timeout_secs",
160                self.transport_idle_timeout_secs,
161            ),
162            (
163                "first_semantic_timeout_secs",
164                self.first_semantic_timeout_secs,
165            ),
166            (
167                "semantic_idle_timeout_secs",
168                self.semantic_idle_timeout_secs,
169            ),
170        ] {
171            if !(MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
172                return Err(format!(
173                    "{name} must be between {MIN_STREAM_TIMEOUT_SECS} and \
174                     {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
175                ));
176            }
177        }
178        Ok(())
179    }
180}
181
182/// Bounds for the initial-request retry policy. The attempt ceiling keeps a
183/// configuration write from widening the HTTP retry loop beyond what the
184/// request budget can pay for.
185pub const MIN_LLM_RETRY_ATTEMPTS: u32 = 1;
186pub const MAX_LLM_RETRY_ATTEMPTS: u32 = 12;
187pub const MIN_LLM_RETRY_DELAY_MS: u64 = 1;
188pub const MAX_LLM_RETRY_DELAY_MS: u64 = 300_000;
189pub const MIN_LLM_RETRY_ATTEMPT_TIMEOUT_MS: u64 = 1_000;
190pub const MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS: u64 = 600_000;
191pub const MIN_LLM_RETRY_TOTAL_BUDGET_MS: u64 = 1_000;
192pub const MAX_LLM_RETRY_TOTAL_BUDGET_MS: u64 = 3_600_000;
193
194fn default_llm_retry_attempts() -> u32 {
195    // Eight attempts absorb a relay blip or a queue spike instead of losing the
196    // turn. An attempt is only re-sent after the previous one failed, so this
197    // raises tolerance without raising the successful-path cost.
198    8
199}
200
201fn default_llm_retry_attempt_timeout_ms() -> u64 {
202    180_000
203}
204
205fn default_llm_retry_total_budget_ms() -> u64 {
206    900_000
207}
208
209fn default_llm_retry_base_delay_ms() -> u64 {
210    500
211}
212
213fn default_llm_retry_max_delay_ms() -> u64 {
214    30_000
215}
216
217/// Initial-request retry policy, operator-tunable from the settings page.
218///
219/// Only establishment of the initial HTTP response is retried; a successful
220/// stream is never replayed. `attempt_timeout_ms` is a *discard* deadline: on
221/// expiry the whole request body is dropped and sent again, so it must clear
222/// real first-byte latency rather than the median.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(default)]
225pub struct LlmRetryConfig {
226    /// Total attempts including the first. `1` disables retrying.
227    #[serde(default = "default_llm_retry_attempts")]
228    pub max_attempts: u32,
229    /// Deadline for response headers on one attempt.
230    #[serde(default = "default_llm_retry_attempt_timeout_ms")]
231    pub attempt_timeout_ms: u64,
232    /// Absolute budget shared by every attempt of one initial request.
233    #[serde(default = "default_llm_retry_total_budget_ms")]
234    pub total_budget_ms: u64,
235    /// Base exponential backoff step.
236    #[serde(default = "default_llm_retry_base_delay_ms")]
237    pub base_delay_ms: u64,
238    /// Upper bound on any single computed backoff sleep.
239    #[serde(default = "default_llm_retry_max_delay_ms")]
240    pub max_delay_ms: u64,
241}
242
243impl Default for LlmRetryConfig {
244    fn default() -> Self {
245        Self {
246            max_attempts: default_llm_retry_attempts(),
247            attempt_timeout_ms: default_llm_retry_attempt_timeout_ms(),
248            total_budget_ms: default_llm_retry_total_budget_ms(),
249            base_delay_ms: default_llm_retry_base_delay_ms(),
250            max_delay_ms: default_llm_retry_max_delay_ms(),
251        }
252    }
253}
254
255impl LlmRetryConfig {
256    /// Reject an out-of-range policy at the API boundary instead of letting it
257    /// widen the retry loop silently.
258    pub fn validate(&self) -> std::result::Result<(), String> {
259        let checks = [
260            (
261                "max_attempts",
262                u64::from(self.max_attempts),
263                u64::from(MIN_LLM_RETRY_ATTEMPTS),
264                u64::from(MAX_LLM_RETRY_ATTEMPTS),
265            ),
266            (
267                "attempt_timeout_ms",
268                self.attempt_timeout_ms,
269                MIN_LLM_RETRY_ATTEMPT_TIMEOUT_MS,
270                MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS,
271            ),
272            (
273                "total_budget_ms",
274                self.total_budget_ms,
275                MIN_LLM_RETRY_TOTAL_BUDGET_MS,
276                MAX_LLM_RETRY_TOTAL_BUDGET_MS,
277            ),
278            (
279                "base_delay_ms",
280                self.base_delay_ms,
281                MIN_LLM_RETRY_DELAY_MS,
282                MAX_LLM_RETRY_DELAY_MS,
283            ),
284            (
285                "max_delay_ms",
286                self.max_delay_ms,
287                MIN_LLM_RETRY_DELAY_MS,
288                MAX_LLM_RETRY_DELAY_MS,
289            ),
290        ];
291        for (name, value, min, max) in checks {
292            if !(min..=max).contains(&value) {
293                return Err(format!(
294                    "{name} must be between {min} and {max}, got {value}"
295                ));
296            }
297        }
298        if self.base_delay_ms > self.max_delay_ms {
299            return Err(format!(
300                "base_delay_ms ({}) must not exceed max_delay_ms ({})",
301                self.base_delay_ms, self.max_delay_ms
302            ));
303        }
304        Ok(())
305    }
306}
307
308/// A user-managed environment variable that is injected into Bash tool processes.
309///
310/// Secret entries are stored by reference in the isolated credential store:
311/// `value` is runtime-only for those entries, while `credential_ref` and
312/// `configured` are the only secret metadata persisted in ordinary config.
313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
314pub struct EnvVarEntry {
315    /// Variable name (must match `^[A-Za-z_][A-Za-z0-9_]*$`).
316    pub name: String,
317    /// Plaintext value – populated in memory after hydration.
318    /// For `secret=true` entries this field is empty on disk.
319    #[serde(default, skip_serializing_if = "String::is_empty")]
320    pub value: String,
321    /// Whether this variable contains sensitive data (token, password, etc.).
322    #[serde(default)]
323    pub secret: bool,
324    /// Legacy inline ciphertext, accepted on read only so startup migration can
325    /// extract it. New serializers never emit this field.
326    #[serde(default, skip_serializing)]
327    pub value_encrypted: Option<String>,
328    /// Stable isolated-store reference for secret entries.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub credential_ref: Option<crate::CredentialRef>,
331    /// Durable status metadata; never inferred from a public mask.
332    #[serde(default)]
333    pub configured: bool,
334    /// Optional human-readable description.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub description: Option<String>,
337}
338
339/// Default work area configuration.
340///
341/// Allows Bamboo to operate without an explicit initial workspace while still
342/// providing a stable fallback directory for relative-path tool execution.
343#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
344pub struct DefaultWorkAreaConfig {
345    /// Optional default filesystem path used when a session has no active workspace.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub path: Option<String>,
348}
349
350/// User-selected locations for new user worktrees. Git remains the registry.
351#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
352pub struct WorktreeSettings {
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub default_root: Option<String>,
355    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
356    pub project_roots: BTreeMap<String, String>,
357}
358
359impl WorktreeSettings {
360    pub fn is_empty(&self) -> bool {
361        self.default_root.is_none() && self.project_roots.is_empty()
362    }
363}
364
365/// Access control configuration for password-based UI/API gating.
366#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
367pub struct AccessControlConfig {
368    /// Whether password protection is enabled.
369    #[serde(default)]
370    pub password_enabled: bool,
371    /// A malformed legacy verifier or device record was isolated into the
372    /// encrypted recovery store and needs an explicit user repair. Runtime
373    /// authorization treats this as fail-closed even when no usable verifier
374    /// can be hydrated.
375    #[serde(default)]
376    pub repair_required: bool,
377    /// Runtime-only password verifier hash. Legacy documents may still
378    /// deserialize it for migration, but ordinary section serialization never
379    /// writes verifier material.
380    #[serde(default, skip_serializing)]
381    pub password_hash: Option<String>,
382    /// Runtime-only password verifier salt.
383    #[serde(default, skip_serializing)]
384    pub password_salt: Option<String>,
385    /// Stable reference to the encrypted verifier record.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub password_credential_ref: Option<crate::CredentialRef>,
388    /// Durable configured metadata for redacted clients/runtime readiness.
389    #[serde(default)]
390    pub password_configured: bool,
391    /// Last update timestamp for auditing / debugging.
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub updated_at: Option<String>,
394    /// v2 (#181): issued per-device tokens. Empty = root-password-only mode
395    /// (back-compat with old instances). Each entry stores only the token hash;
396    /// the plaintext token is returned to the client once at pairing time.
397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
398    pub devices: Vec<DeviceCredential>,
399}
400
401/// A single paired device's credential (v2-P2 per-device token, #181).
402///
403/// The server stores only `token_hash` (never the plaintext token). The hash is
404/// computed with the SAME construction as the access password — `SHA-256(salt ||
405/// token)` — so no new crypto dependency is introduced (`docs/api-v2-transport.md`
406/// §4.2).
407#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
408pub struct DeviceCredential {
409    /// Server-generated stable id: `bamboo_<12 hex>`.
410    pub device_id: String,
411    /// Human-readable label, e.g. "iPhone 15".
412    pub label: String,
413    /// Runtime-only `SHA-256(hex_decode(token_salt) || token)`, hex-encoded.
414    #[serde(default, skip_serializing)]
415    pub token_hash: String,
416    /// Runtime-only per-device salt (hex-encoded).
417    #[serde(default, skip_serializing)]
418    pub token_salt: String,
419    /// Stable reference to the encrypted device-token verifier record.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub token_credential_ref: Option<crate::CredentialRef>,
422    /// Durable configured metadata for runtime readiness.
423    #[serde(default)]
424    pub token_configured: bool,
425    /// RFC3339 creation timestamp.
426    pub created_at: String,
427    /// RFC3339 last-used timestamp (deferred stamping; see PR).
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub last_used_at: Option<String>,
430    /// Whether this device's token has been revoked. A revoked token is rejected
431    /// at the handshake/middleware immediately.
432    #[serde(default)]
433    pub revoked: bool,
434}
435
436/// Memory and background summarization configuration.
437// No `Eq`: `dedup_gardener_min_score` is an f64 (PartialEq only).
438#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
439pub struct MemoryConfig {
440    /// Optional dedicated model for memory/session summarization and reflection.
441    /// Falls back to the provider fast model when unset.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub background_model: Option<String>,
444    /// Desired conversation-summary size as a fraction of the raw source tokens
445    /// represented by that summary. The default keeps roughly 20% of source
446    /// content as durable working memory.
447    #[serde(default = "default_summary_target_ratio")]
448    pub summary_target_ratio: f64,
449    /// Maximum fraction of the summarization model context window consumed by a
450    /// fully rendered map/reduce request, including reserved output and safety
451    /// margin.
452    #[serde(default = "default_summary_safe_window_percent")]
453    pub summary_safe_window_percent: u8,
454    /// Whether lightweight automatic Dream-style consolidation should run in the
455    /// background. Default ON (memory redesign L4): each tick no-ops when there is
456    /// no background model configured or no new candidate sessions, so it is free
457    /// until there is real work + a model. Set false to opt out.
458    #[serde(default = "default_true_auto_dream_enabled")]
459    pub auto_dream_enabled: bool,
460    /// Seconds between background auto-Dream ticks (default 30 minutes).
461    /// Each tick still no-ops when there are no new candidate sessions, so raising
462    /// this only lowers how often an active user triggers a real consolidation.
463    #[serde(default = "default_auto_dream_interval_secs")]
464    pub auto_dream_interval_secs: u64,
465    /// Whether project durable-memory index injection is enabled for the main prompt.
466    #[serde(
467        default = "default_true_memory_project_prompt_injection",
468        alias = "memory_project_prompt_injection"
469    )]
470    pub project_prompt_injection: bool,
471    /// Whether automatic relevant durable-memory recall is enabled for the main prompt.
472    #[serde(
473        default = "default_true_memory_relevant_recall",
474        alias = "memory_relevant_recall"
475    )]
476    pub relevant_recall: bool,
477    /// Whether relevant durable-memory recall should rerank lexical shortlist candidates
478    /// using the configured memory/background model.
479    #[serde(default, alias = "memory_relevant_recall_rerank")]
480    pub relevant_recall_rerank: bool,
481    /// Whether Dream prompt injection should prefer project Dream and only use global Dream as fallback.
482    #[serde(
483        default = "default_true_memory_project_first_dream",
484        alias = "memory_project_first_dream"
485    )]
486    pub project_first_dream: bool,
487    /// Whether the ledger agenda (overdue/upcoming prospective records — todos,
488    /// events, reminders) is injected into the main prompt. Free when the
489    /// ledger is empty: the section is simply omitted.
490    #[serde(
491        default = "default_true_memory_ledger_agenda",
492        alias = "memory_ledger_agenda_injection"
493    )]
494    pub ledger_agenda_injection: bool,
495    /// Whether the background ledger gardener runs (expires past events/reminders,
496    /// reconciles record↔schedule drift, distills completed records into durable
497    /// memory). Expiry and reconciliation are deterministic and free; only
498    /// distillation uses the background model, and it no-ops without one.
499    #[serde(default = "default_true_ledger_gardener_enabled")]
500    pub ledger_gardener_enabled: bool,
501    /// Seconds between ledger gardener runs (default 6 hours).
502    #[serde(default = "default_ledger_gardener_interval_secs")]
503    pub ledger_gardener_interval_secs: u64,
504    /// Whether the ledger gardener's distillation pass (completed records →
505    /// durable memories via the background model) is enabled.
506    #[serde(default = "default_true_ledger_distillation_enabled")]
507    pub ledger_distillation_enabled: bool,
508    /// DEPRECATED (memory redesign L3): the "Refine" Dream mode — rewriting the
509    /// notebook from its own prior prose — was retired because a self-referential
510    /// narrative rewrite drifts from durable truth and silently over-merges. The
511    /// notebook is now always a grounded VIEW of the durable memory index (Rebuild)
512    /// or a session bootstrap (Incremental). This field is IGNORED; it is retained
513    /// only so existing config files that set it still deserialize.
514    #[serde(default, alias = "memory_dream_refine_mode")]
515    pub dream_refine_mode: bool,
516    /// Whether the background "gardener" may use the LLM to split/merge "blob" memories.
517    /// Default ON (memory redesign L4). The deterministic blob prefilter is cheap
518    /// and each run is bounded by `gardener_max_splits_per_run`; a run that finds
519    /// nothing, or finds work but has no background model, spends no tokens. Set
520    /// false to opt out.
521    #[serde(
522        default = "default_true_gardener_enabled",
523        alias = "memory_gardener_enabled"
524    )]
525    pub gardener_enabled: bool,
526    /// Seconds between gardener time-triggered runs (default daily). A run may also
527    /// fire early when the library grows — see `gardener_volume_trigger`.
528    #[serde(default = "default_gardener_interval_secs")]
529    pub gardener_interval_secs: u64,
530    /// Run the gardener maintenance pass early (before the next time tick) once this
531    /// many new durable memories have accumulated since the last run, so pileup is
532    /// bounded by growth, not only by the clock (memory redesign L4). 0 disables the
533    /// volume trigger (time-only). Per-run caps still bound the work done.
534    #[serde(default = "default_gardener_volume_trigger")]
535    pub gardener_volume_trigger: usize,
536    /// Hard cap on LLM-backed splits per gardener run (cost ceiling per run).
537    #[serde(default = "default_gardener_max_splits_per_run")]
538    pub gardener_max_splits_per_run: usize,
539    /// Minimum `---` accretions for a memory to be a gardener split candidate.
540    #[serde(default = "default_gardener_min_sections")]
541    pub gardener_min_sections: usize,
542    /// Whether the background dedup gardener may use the LLM to consolidate
543    /// near-duplicate memories. Default ON (memory redesign L4); bounded by
544    /// `dedup_gardener_max_merges_per_run` and no-ops without a model. Set false to
545    /// opt out.
546    #[serde(
547        default = "default_true_dedup_gardener_enabled",
548        alias = "memory_dedup_gardener_enabled"
549    )]
550    pub dedup_gardener_enabled: bool,
551    /// Minimum content-keyword Jaccard (0.0–1.0) for two active memories to be
552    /// flagged as dedup candidates by the deterministic prefilter.
553    #[serde(default = "default_dedup_gardener_min_score")]
554    pub dedup_gardener_min_score: f64,
555    /// Hard cap on LLM-backed consolidations per dedup gardener run (cost ceiling).
556    #[serde(default = "default_dedup_gardener_max_merges_per_run")]
557    pub dedup_gardener_max_merges_per_run: usize,
558    /// Max RECALLABLE (Active/Stale) memories per scope before the capacity gardener
559    /// archives the lowest-value overflow OUT of the recall index (memory redesign
560    /// L5 — archive, never delete; reversible). 0 = unbounded (feature OFF, the
561    /// default): consequential enough to be opt-in, since L4's dedup already curbs
562    /// most growth. `Reference`/`User`/`Feedback` memories are always exempt — so
563    /// the effective floor is the count of exempt Active memories in a scope; set
564    /// this comfortably above that (a capacity below it is a no-op, not a purge).
565    #[serde(default)]
566    pub memory_active_capacity: usize,
567    /// Hard cap on how many memories the capacity gardener archives per run, so a
568    /// large overflow drains gradually instead of in one burst.
569    #[serde(default = "default_capacity_max_archivals_per_run")]
570    pub capacity_max_archivals_per_run: usize,
571    /// Whether the background freshness gardener may conservatively demote Active
572    /// day/week-granularity memories to Stale once they cross their documented
573    /// staleness window (issue #61 phase 2; applied by Jiandu's
574    /// `MemoryStore::expire_stale_granularity`). Default ON,
575    /// matching the other gardener passes: deterministic (no LLM, no cost), and
576    /// non-destructive — it only ever moves Active → Stale, never archives or
577    /// deletes. Set false to opt out.
578    #[serde(default = "default_true_granularity_freshness_gardener_enabled")]
579    pub granularity_freshness_gardener_enabled: bool,
580}
581
582impl Default for MemoryConfig {
583    fn default() -> Self {
584        Self {
585            background_model: None,
586            summary_target_ratio: default_summary_target_ratio(),
587            summary_safe_window_percent: default_summary_safe_window_percent(),
588            auto_dream_enabled: default_true_auto_dream_enabled(),
589            auto_dream_interval_secs: default_auto_dream_interval_secs(),
590            project_prompt_injection: default_true_memory_project_prompt_injection(),
591            relevant_recall: default_true_memory_relevant_recall(),
592            relevant_recall_rerank: false,
593            project_first_dream: default_true_memory_project_first_dream(),
594            ledger_agenda_injection: default_true_memory_ledger_agenda(),
595            ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
596            ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
597            ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
598            dream_refine_mode: false,
599            gardener_enabled: default_true_gardener_enabled(),
600            gardener_interval_secs: default_gardener_interval_secs(),
601            gardener_volume_trigger: default_gardener_volume_trigger(),
602            gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
603            gardener_min_sections: default_gardener_min_sections(),
604            dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
605            dedup_gardener_min_score: default_dedup_gardener_min_score(),
606            dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
607            memory_active_capacity: 0,
608            capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
609            granularity_freshness_gardener_enabled:
610                default_true_granularity_freshness_gardener_enabled(),
611        }
612    }
613}
614
615fn default_summary_target_ratio() -> f64 {
616    0.20
617}
618
619fn default_summary_safe_window_percent() -> u8 {
620    80
621}
622
623fn default_true_granularity_freshness_gardener_enabled() -> bool {
624    true
625}
626
627fn default_capacity_max_archivals_per_run() -> usize {
628    50
629}
630
631fn default_true_auto_dream_enabled() -> bool {
632    true
633}
634
635fn default_true_gardener_enabled() -> bool {
636    true
637}
638
639fn default_true_dedup_gardener_enabled() -> bool {
640    true
641}
642
643fn default_true_memory_ledger_agenda() -> bool {
644    true
645}
646
647fn default_true_ledger_gardener_enabled() -> bool {
648    true
649}
650
651fn default_ledger_gardener_interval_secs() -> u64 {
652    21_600
653}
654
655fn default_true_ledger_distillation_enabled() -> bool {
656    true
657}
658
659/// Fire the gardener maintenance pass early once ~this many new memories accumulate
660/// since the last run. Conservative: large enough to avoid thrashing on a few
661/// writes, small enough to bound pileup well under a full (daily) interval.
662fn default_gardener_volume_trigger() -> usize {
663    25
664}
665
666fn default_gardener_interval_secs() -> u64 {
667    86_400
668}
669
670fn default_auto_dream_interval_secs() -> u64 {
671    60 * 30
672}
673
674fn default_gardener_max_splits_per_run() -> usize {
675    8
676}
677
678fn default_gardener_min_sections() -> usize {
679    5
680}
681
682fn default_dedup_gardener_min_score() -> f64 {
683    0.6
684}
685
686fn default_dedup_gardener_max_merges_per_run() -> usize {
687    8
688}
689
690fn default_true_memory_project_prompt_injection() -> bool {
691    true
692}
693
694fn default_true_memory_relevant_recall() -> bool {
695    true
696}
697
698fn default_true_memory_project_first_dream() -> bool {
699    true
700}
701
702/// Per-run resource guardrails (issue #221): a cost/resource ceiling applied
703/// across an entire `AgentRuntime::execute()` call (i.e. one user turn's worth
704/// of internal rounds — the same "run" granularity the round cap uses).
705///
706/// Every field is `None` by default (unlimited), matching the rest of this
707/// config's opt-in-only posture. A per-request `ExecuteRequest::run_budget`
708/// override (HTTP `POST /execute` body) may only TIGHTEN this config-level
709/// default, never loosen it — per field, the effective limit is the minimum
710/// of the two (see [`RunBudgetConfig::merged_with_override`] and
711/// `bamboo_engine::runtime::runtime::AgentRuntime::execute`).
712///
713/// Exceeding any configured limit gracefully stops the run (mirrors the
714/// round-cap exhaustion path: one final summary turn, then a terminal stop
715/// with `runtime.completion_reason` = `"budget_exceeded"` for resource fields
716/// and `"max_rounds_reached"` for the round cap, plus a structured
717/// `AgentEvent::BudgetExceeded`) rather than erroring out — the run stays
718/// resumable.
719#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
720pub struct RunBudgetConfig {
721    /// Maximum total tokens (prompt + completion, actual provider-reported
722    /// usage summed across the run's rounds) before the run is stopped.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub max_total_tokens: Option<u64>,
725    /// Maximum total tool calls (across every round of the run, not just one
726    /// round — see `max_tool_calls_per_round` for the existing per-round cap)
727    /// before the run is stopped.
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub max_tool_calls: Option<u32>,
730    /// Maximum total `SubAgent` create calls (across the whole run) before the
731    /// run is stopped. Distinct from `subagents.max_concurrent`, which caps how
732    /// many child actor processes run AT ONCE, not how many a single run may
733    /// spawn in total over its lifetime.
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    pub max_subagents: Option<u32>,
736    /// Maximum internal rounds for a single run before the run is gracefully
737    /// stopped. `None` (the default) means unlimited: the loop runs until the
738    /// model stops calling tools, another guardrail trips, or the run is
739    /// cancelled. Setting a cap keeps the issue #29 exhaustion contract
740    /// (`runtime.completion_reason = "max_rounds_reached"`, a visible
741    /// notification, and exactly one final summary turn).
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub max_rounds: Option<u32>,
744}
745
746/// Tighten-only per-field merge: the effective limit is the MINIMUM of the
747/// config default and the request override, with `None` = unlimited.
748fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
749    match (config_default, request) {
750        (Some(a), Some(b)) => Some(a.min(b)),
751        (Some(a), None) => Some(a),
752        (None, Some(b)) => Some(b),
753        (None, None) => None,
754    }
755}
756
757impl RunBudgetConfig {
758    /// Merge a per-request override with this config-level default,
759    /// **tighten-only** (issue #221, PR #539 review): per field, the
760    /// effective limit is the MINIMUM of the two (`None` = unlimited), so a
761    /// `POST /execute` caller can lower a budget below the operator's
762    /// configured ceiling but can never raise or remove it.
763    ///
764    /// Rationale: `run_budget` is a defensive cost circuit-breaker, and the
765    /// server's other guardrails (`max_rounds`, per-round tool caps, …) are
766    /// not client-overridable at all. A client-loosenable ceiling would be no
767    /// ceiling: any caller of `/execute` could send
768    /// `max_total_tokens: u64::MAX` and erase the operator's cap. Overrides
769    /// looser than the config default are silently clamped to it rather than
770    /// rejected — the caller still gets the strictest applicable budget,
771    /// which is always a safe interpretation of their request.
772    pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
773        let Some(over) = request_override else {
774            return *self;
775        };
776        Self {
777            max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
778            max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
779            max_subagents: min_limit(self.max_subagents, over.max_subagents),
780            max_rounds: min_limit(self.max_rounds, over.max_rounds),
781        }
782    }
783}
784
785/// Authentication/provider posture for `executor = "codex"`.
786///
787/// `None` at the containing config field is interpreted as [`Self::Bamboo`],
788/// keeping old documents backward-compatible while making the safe,
789/// parent-routed mode the runtime default.
790#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
791#[serde(rename_all = "snake_case")]
792pub enum CodexAuthMode {
793    /// Use the invoking user's default `~/.codex` auth and configuration.
794    Inherit,
795    /// Isolated `CODEX_HOME`; `OPENAI_API_KEY` must be explicitly forwarded.
796    ApiKey,
797    /// Isolated `CODEX_HOME` with a custom provider and referenced credential.
798    Custom,
799    /// Isolated `CODEX_HOME`; route through this Bamboo server with a per-run token.
800    #[default]
801    Bamboo,
802}
803
804/// Codex 0.144+ only accepts the Responses wire protocol for custom providers.
805#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
806#[serde(rename_all = "snake_case")]
807pub enum CodexWireApi {
808    #[default]
809    Responses,
810}
811
812/// Codex transport used by `executor = "codex"`.
813#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
814#[serde(rename_all = "snake_case")]
815pub enum CodexMode {
816    /// One `codex exec --json` process per activation.
817    #[default]
818    Exec,
819    /// Long-lived `codex app-server` JSON-RPC session with approval relay.
820    AppServer,
821}
822
823/// OS sandbox selected for non-interactive `codex exec` children.
824#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
825#[serde(rename_all = "kebab-case")]
826pub enum CodexSandbox {
827    ReadOnly,
828    WorkspaceWrite,
829    DangerFullAccess,
830}
831
832/// Codex approval policy. Mode-specific validation rejects interactive policy
833/// in exec mode and non-interactive policy in app-server mode.
834#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
835#[serde(rename_all = "kebab-case")]
836pub enum CodexApprovalPolicy {
837    Never,
838    OnFailure,
839    OnRequest,
840}
841
842/// Default number of child levels below a Root (whose depth is zero).
843pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
844
845/// Sub-agent execution settings.
846///
847/// Sub-agents always run as independent **actor** processes — an isolated OS
848/// process with its own context (crash isolation, true parallelism, per-child
849/// resource limits). The historical in-process runtime was removed, so there is
850/// no longer a runtime toggle (a stray `"runtime"`/`"overrides"` key in an old
851/// config is ignored). The worker binary, its arguments, and the discovery
852/// directory are derived automatically (the current `bamboo` executable +
853/// `subagent-worker`); the expert fields below override them only when you run a
854/// custom worker.
855#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
856pub struct SubagentsConfig {
857    /// Maximum child depth below Root=0. Zero disables new child creation.
858    /// Unset preserves the historical four-level limit.
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub max_spawn_depth: Option<u32>,
861    /// Maximum actor activations running at once; further spawns wait their
862    /// turn. Default: 200. Warm-idle process retention has a separate, smaller
863    /// bound.
864    #[serde(default, skip_serializing_if = "Option::is_none")]
865    pub max_concurrent: Option<usize>,
866    /// Expert: custom worker binary. Default: the current bamboo executable.
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub worker_bin: Option<String>,
869    /// Expert: arguments for the custom worker binary. Default for the
870    /// built-in worker: `["subagent-worker"]`.
871    #[serde(default, skip_serializing_if = "Option::is_none")]
872    pub worker_args: Option<Vec<String>>,
873    /// Expert: discovery fabric directory. Default: a per-user temp dir.
874    #[serde(default, skip_serializing_if = "Option::is_none")]
875    pub fabric_dir: Option<String>,
876    /// Expert: `"echo"` swaps in a dependency-free smoke executor (no LLM)
877    /// to verify the actor chain end-to-end; `"claude_code"` drives the
878    /// official Claude Code CLI; `"codex"` drives the selected Codex mode.
879    #[serde(default, skip_serializing_if = "Option::is_none")]
880    pub executor: Option<String>,
881    /// `executor = "claude_code"` only: override the `claude` executable.
882    /// `None` runs `claude` resolved from `PATH`.
883    #[serde(default, skip_serializing_if = "Option::is_none")]
884    pub claude_code_binary: Option<String>,
885    /// `executor = "claude_code"` only: `--model` override. `None` omits the
886    /// flag (CLI default).
887    #[serde(default, skip_serializing_if = "Option::is_none")]
888    pub claude_code_model: Option<String>,
889    /// `executor = "claude_code"` only: `--permission-mode` override. `None`
890    /// still passes an EXPLICIT `default` to the CLI (issue #443 — the
891    /// headless stream-json default is `auto`, which self-approves every
892    /// tool and never asks); it does not mean "omit the flag".
893    #[serde(default, skip_serializing_if = "Option::is_none")]
894    pub claude_code_permission_mode: Option<String>,
895    /// `executor = "claude_code"` only: `true` lets the child inherit the
896    /// invoking user's `~/.claude` MCP servers/skills/settings. `false`/unset
897    /// (the default) isolates it (`--strict-mcp-config` +
898    /// `--setting-sources project`).
899    #[serde(default, skip_serializing_if = "Option::is_none")]
900    pub claude_code_inherit_user_config: Option<bool>,
901    /// `executor = "claude_code"` only: extra env var NAMES forwarded
902    /// verbatim from this process's env to the child, on top of the fixed
903    /// HOME/PATH/SHELL/TERM/LANG/LC_*/TMPDIR/USER/LOGNAME allowlist.
904    /// Forwarding `ANTHROPIC_API_KEY` here is an explicit opt-in that flips
905    /// billing from the CLI's own subscription auth to the API key.
906    #[serde(default, skip_serializing_if = "Option::is_none")]
907    pub claude_code_forward_env: Option<Vec<String>>,
908    /// `executor = "codex"` only: override the `codex` executable. `None`
909    /// resolves `codex` from `PATH`.
910    #[serde(default, skip_serializing_if = "Option::is_none")]
911    pub codex_binary: Option<String>,
912    /// `executor = "codex"` only: `--model` override. `None` uses the CLI
913    /// default model.
914    #[serde(default, skip_serializing_if = "Option::is_none")]
915    pub codex_model: Option<String>,
916    /// `executor = "codex"` only: `exec` (default) or long-lived `app_server`.
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    pub codex_mode: Option<CodexMode>,
919    /// `executor = "codex"` only: authentication/provider mode. Unset defaults
920    /// to `bamboo`, which keeps provider credentials in the parent process.
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub codex_auth_mode: Option<CodexAuthMode>,
923    /// `codex_auth_mode = "custom"` only: absolute HTTP(S) provider base URL.
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub codex_base_url: Option<String>,
926    /// `codex_auth_mode = "custom"` only. Codex 0.144+ supports `responses`.
927    #[serde(default, skip_serializing_if = "Option::is_none")]
928    pub codex_wire_api: Option<CodexWireApi>,
929    /// `codex_auth_mode = "custom"` only: stable reference to an existing
930    /// provider credential. The plaintext key remains in the credential store
931    /// and is injected into the child process environment only.
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub codex_provider_key_ref: Option<crate::CredentialRef>,
934    /// Extra env var names copied into Codex after `env_clear()`. API-key mode
935    /// requires an explicit `OPENAI_API_KEY` entry; it is never implicit.
936    #[serde(default, skip_serializing_if = "Option::is_none")]
937    pub codex_forward_env: Option<Vec<String>>,
938    /// Explicit Codex sandbox override. Unset derives the sandbox from the
939    /// child permission profile and the parent session's bypass posture.
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    pub codex_sandbox: Option<CodexSandbox>,
942    /// Mode-specific approval policy. Exec resolves to a non-interactive safe
943    /// value; app-server requires `on-request` and routes it to the parent.
944    #[serde(default, skip_serializing_if = "Option::is_none")]
945    pub codex_approval_policy: Option<CodexApprovalPolicy>,
946    /// Permit network access from a workspace-write sandbox.
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    pub codex_network_access: Option<bool>,
949    /// Opt in to disabling the Codex OS sandbox, but only when the parent run
950    /// is itself in bypass mode. False/unset keeps bypass runs sandboxed.
951    #[serde(default, skip_serializing_if = "Option::is_none")]
952    pub codex_allow_danger_bypass: Option<bool>,
953    /// The active message-broker endpoint the `ask_agent` tool / sub-agent bus
954    /// dials. RUNTIME-ONLY (`#[serde(skip)]`): never read from nor written to
955    /// `config.json`. It is populated in memory each boot by `maybe_embed_broker`
956    /// — either from a user-managed external broker in `<data_dir>/broker.json`,
957    /// or from the freshly-embedded in-process broker (whose ephemeral loopback
958    /// port must NEVER be persisted, else a later boot dials a dead port).
959    #[serde(skip)]
960    pub broker: Option<BrokerClientConfig>,
961    /// Remote placements: pin specific sub-agent roles to resident workers
962    /// reached over `wss://` instead of a locally-spawned subprocess
963    /// (remote-actor-plan §3.4 / P1.5, #193). Empty (the default) keeps every
964    /// role on the local path — fully back-compatible: an old config with no
965    /// `remote_placements` key deserializes to an empty vec.
966    #[serde(default, skip_serializing_if = "Vec::is_empty")]
967    pub remote_placements: Vec<RemoteActorPlacement>,
968    /// Schedulable placements: route specific sub-agent roles to a LIVE worker
969    /// resolved from the agent registry at run time, instead of a locally-spawned
970    /// subprocess (remote-actor-plan §3.4 / P2b, #181). Unlike `remote_placements`
971    /// (a fixed endpoint), a schedulable placement names a logical `pool` and a
972    /// `registry_url`; the engine queries the registry for live workers in that
973    /// pool and picks one. Empty (the default) keeps every role on the local path
974    /// — fully back-compatible: an old config with no `schedulable_placements` key
975    /// deserializes to an empty vec.
976    ///
977    /// PRECEDENCE: if a role appears in BOTH `remote_placements` and
978    /// `schedulable_placements`, the fixed remote placement wins (it is resolved
979    /// first in `build_spec`).
980    #[serde(default, skip_serializing_if = "Vec::is_empty")]
981    pub schedulable_placements: Vec<SchedulablePlacement>,
982    /// Per-role allowlist scoping which host-bound MCP tools a sub-agent role
983    /// may see/call through the orchestrator's MCP proxy (issue #54;
984    /// `bamboo_broker::RoleToolAllowlist`). Read and enforced
985    /// ORCHESTRATOR-side when wiring `serve_mcp_proxy` — this is deliberately
986    /// NOT part of the worker-facing `McpProxyConfig` a deployed worker
987    /// receives, because a worker self-declaring its own allowlist would be
988    /// insecure (it could simply claim to be unrestricted). A role absent
989    /// from this list is unrestricted (sees/can call every proxiable tool),
990    /// so adding this policy never silently strips tools from an
991    /// already-deployed role you have not listed here. Empty (the default)
992    /// keeps every role unrestricted — fully backward compatible with
993    /// pre-#54 behavior.
994    ///
995    /// Role AND tool names are matched by exact string equality against the
996    /// worker-asserted `AgentRef.role` / the requested tool name — see
997    /// `RoleToolAllowlist`'s doc comment for the resulting self-asserted-role
998    /// caveat (this policy is adequate against a confused/hallucinating
999    /// worker, not a malicious one that lies about its own role).
1000    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1001    pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
1002}
1003
1004impl SubagentsConfig {
1005    pub fn effective_max_spawn_depth(&self) -> u32 {
1006        self.max_spawn_depth.unwrap_or(DEFAULT_MAX_SPAWN_DEPTH)
1007    }
1008}
1009
1010/// One role's MCP proxy tool allowlist entry (issue #54). See
1011/// [`SubagentsConfig::mcp_role_allowlist`].
1012#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1013pub struct McpRoleAllowlistEntry {
1014    /// Sub-agent role this entry restricts — matches the worker-asserted
1015    /// `AgentRef.role` (itself the child session's `subagent_type` /
1016    /// `ChildIdentity.role`). There is no fixed registry of valid roles in
1017    /// this codebase (roles are free-form profile ids), so a typo here is
1018    /// NOT caught against a "known roles" list — only structurally (blank
1019    /// names are dropped, duplicates warn) at load time. Double-check this
1020    /// against the role string your profile/deploy config actually uses.
1021    pub role: String,
1022    /// Tool names this role may see in its manifest / call through the
1023    /// proxy, matched by exact string equality against the backend's
1024    /// registered tool name. An entry with an EMPTY list is an explicit
1025    /// lockout (no tools) for that role, distinct from the role being absent
1026    /// from this Vec entirely (unrestricted). Validated at load time against
1027    /// the orchestrator's live MCP tool set where available — an unknown
1028    /// name is still enforced (kept) but logged as a likely typo.
1029    #[serde(default)]
1030    pub tools: Vec<String>,
1031}
1032
1033/// Operator-owned hard constraints for a remote WorkerHost. The broker's
1034/// authenticated peer policy advertises capacity; it does not choose the
1035/// trust/data boundary for a Child. Missing requirements leave an explicitly
1036/// selected remote placement unavailable rather than inferring authority from
1037/// the worker's own capabilities.
1038#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1039#[serde(deny_unknown_fields)]
1040pub struct OperatorPlacementRequirements {
1041    pub trust_zone: String,
1042    #[serde(default, skip_serializing_if = "Option::is_none")]
1043    pub workspace_label: Option<String>,
1044    #[serde(default, skip_serializing_if = "Option::is_none")]
1045    pub network_zone: Option<String>,
1046    #[serde(default)]
1047    pub require_network_isolation: bool,
1048    /// Operator-declared tools the selected Host must support. The runner adds
1049    /// a native tool ceiling when one is bound to the Child.
1050    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
1051    pub required_tools: std::collections::BTreeSet<String>,
1052}
1053
1054/// The fixed parent identity used by a scoped broker pool. Every candidate
1055/// WorkerHost is separately selected by its authenticated observation.
1056#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1057#[serde(deny_unknown_fields)]
1058pub struct SchedulableBrokerParent {
1059    pub parent_mailbox: String,
1060    pub parent_role: String,
1061}
1062
1063/// Route a role to a capacity-managed WorkerHost pool through a scoped WSS
1064/// broker. The operator supplies the parent identity and hard constraints;
1065/// legacy entries remain readable but unavailable until migrated.
1066#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1067pub struct SchedulablePlacement {
1068    /// Sub-agent role this targets (matches the child session's
1069    /// `metadata["subagent_type"]`).
1070    pub role: String,
1071    /// Logical pool name — the registry `role` to query for live workers.
1072    pub pool: String,
1073    /// Scoped WSS broker endpoint. The old HTTP registry was retired.
1074    #[serde(default, skip_serializing_if = "String::is_empty")]
1075    pub registry_url: String,
1076    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
1077    /// `auth_ref`). Used for BOTH the registry query and the worker connect.
1078    /// `None` ⇒ query/connect without a bearer (trusted link only).
1079    #[serde(default, skip_serializing_if = "Option::is_none")]
1080    pub token_env: Option<String>,
1081    /// PEM file pinning the scoped broker certificate.
1082    #[serde(default, skip_serializing_if = "Option::is_none")]
1083    pub ca_cert_file: Option<String>,
1084    /// Required for authority-backed scheduling. `registry_url` is the scoped
1085    /// WSS broker URL for this route; legacy entries remain readable but cannot
1086    /// dispatch a Run without a trusted parent identity and requirements.
1087    #[serde(default, skip_serializing_if = "Option::is_none")]
1088    pub broker_parent: Option<SchedulableBrokerParent>,
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub placement_requirements: Option<OperatorPlacementRequirements>,
1091}
1092
1093/// Pins a single sub-agent role to a remote resident worker (remote-actor-plan
1094/// §3.4 / P1.5). A child whose `subagent_type` matches `role` is connected over
1095/// `wss://` to `endpoint` (Bearer-authenticated) instead of being spawned as a
1096/// local subprocess. No role match ⇒ that child stays on the local path.
1097///
1098/// The bearer token is NEVER stored here in the clear: `token_env` names the
1099/// environment variable that holds it (mirroring the A2A `auth_ref` pattern),
1100/// read once at runner-build time. A `token_env` that is set-but-unset at build
1101/// time leaves an explicit placement unavailable. A `broker_peer` selection
1102/// is required for execution; legacy direct-worker config remains readable.
1103#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1104pub struct RemoteActorPlacement {
1105    /// Sub-agent role this targets (matches the child session's
1106    /// `metadata["subagent_type"]`).
1107    pub role: String,
1108    /// Broker endpoint for a scoped peer route. Legacy worker endpoints remain
1109    /// readable but are unavailable until migrated to a broker route.
1110    pub endpoint: String,
1111    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
1112    /// `auth_ref`). `None` ⇒ connect without a bearer (trusted link only).
1113    #[serde(default, skip_serializing_if = "Option::is_none")]
1114    pub token_env: Option<String>,
1115    /// PEM file pinning a self-signed worker cert. `None` ⇒ default webpki roots
1116    /// (or plaintext `ws://`).
1117    #[serde(default, skip_serializing_if = "Option::is_none")]
1118    pub ca_cert_file: Option<String>,
1119    /// Explicit scoped broker route. Absence keeps the placement selected but
1120    /// unavailable, never a direct parent-to-worker connection.
1121    #[serde(default, skip_serializing_if = "Option::is_none")]
1122    pub broker_peer: Option<RemoteBrokerPeer>,
1123    #[serde(default, skip_serializing_if = "Option::is_none")]
1124    pub placement_requirements: Option<OperatorPlacementRequirements>,
1125}
1126
1127/// Operator-pinned transport identities; these do not grant Actor ownership.
1128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1129#[serde(deny_unknown_fields)]
1130pub struct RemoteBrokerPeer {
1131    pub parent_mailbox: String,
1132    pub worker_mailbox: String,
1133    pub parent_role: Option<String>,
1134    pub worker_role: Option<String>,
1135}
1136impl RemoteBrokerPeer {
1137    pub fn valid(&self) -> bool {
1138        fn identifier(s: &str, mailbox: bool) -> bool {
1139            !s.is_empty()
1140                && s.len() <= 256
1141                && s != "."
1142                && s != ".."
1143                && s.bytes()
1144                    .all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
1145                && (!mailbox || !s.bytes().any(|b| b.is_ascii_uppercase()))
1146        }
1147        identifier(&self.parent_mailbox, true)
1148            && identifier(&self.worker_mailbox, true)
1149            && self.parent_mailbox != self.worker_mailbox
1150            && self
1151                .parent_role
1152                .as_deref()
1153                .is_none_or(|s| identifier(s, false))
1154            && self
1155                .worker_role
1156                .as_deref()
1157                .is_some_and(|s| identifier(s, false))
1158    }
1159}
1160
1161/// How to reach the central sub-agent message broker (`bamboo broker serve`).
1162#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
1163pub struct BrokerClientConfig {
1164    /// Broker WebSocket endpoint, e.g. `ws://broker-host:9600`.
1165    pub endpoint: String,
1166    /// Bearer token presented in the broker handshake.
1167    ///
1168    /// Runtime-only plaintext hydrated from the isolated credential store.
1169    /// Legacy `broker.json` files may still deserialize this field so the
1170    /// manifest migration can extract it, but serializers never emit it.
1171    #[serde(default, skip_serializing)]
1172    pub token: String,
1173    /// Legacy inline ciphertext accepted only for credential migration.
1174    #[serde(default, skip_serializing)]
1175    pub token_encrypted: Option<String>,
1176    /// Stable reference to the external broker bearer token.
1177    #[serde(default, skip_serializing_if = "Option::is_none")]
1178    pub credential_ref: Option<crate::CredentialRef>,
1179    /// Durable configured metadata. Runtime hydration still verifies that the
1180    /// referenced credential exists and decrypts successfully.
1181    #[serde(default)]
1182    pub configured: bool,
1183}
1184
1185impl std::fmt::Debug for BrokerClientConfig {
1186    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1187        formatter
1188            .debug_struct("BrokerClientConfig")
1189            .field("endpoint", &self.endpoint)
1190            .field("credential_ref", &self.credential_ref)
1191            .field("configured", &self.configured)
1192            .finish_non_exhaustive()
1193    }
1194}
1195
1196/// Native desktop (OS-notification) delivery channel.
1197#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1198pub struct DesktopChannelConfig {
1199    /// `None` = auto: on when Bamboo runs as a standalone `bamboo serve`
1200    /// process, off when spawned as a sidecar under `--parent-pid` (a native
1201    /// shell such as Bodhi owns notification UX in that mode — desktop
1202    /// notifications from both the sidecar and the shell would double-fire).
1203    /// `Some(_)` is an explicit user override of that default in either
1204    /// direction.
1205    #[serde(default, skip_serializing_if = "Option::is_none")]
1206    pub enabled: Option<bool>,
1207}
1208
1209/// [ntfy.sh](https://ntfy.sh) push notification channel (self-hostable).
1210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1211pub struct NtfyChannelConfig {
1212    #[serde(default)]
1213    pub enabled: bool,
1214    /// ntfy server base URL (public ntfy.sh or a self-hosted instance).
1215    #[serde(default = "default_ntfy_base_url")]
1216    pub base_url: String,
1217    /// Topic to publish to. Priority mapping from notification category is
1218    /// left to the delivery sink, not configured here.
1219    #[serde(default)]
1220    pub topic: String,
1221    /// Access token for a protected/self-hosted ntfy instance (public ntfy.sh
1222    /// topics need none).
1223    ///
1224    /// Secret plaintext hydrated in memory from the isolated credential store;
1225    /// never serialized in ordinary config.
1226    #[serde(default, skip_serializing)]
1227    pub token: Option<String>,
1228    /// Legacy encrypted ciphertext accepted only for migration.
1229    #[serde(default, skip_serializing)]
1230    pub token_encrypted: Option<String>,
1231    /// Stable isolated credential-store reference.
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    pub credential_ref: Option<crate::CredentialRef>,
1234    /// Whether the referenced credential is expected to exist.
1235    #[serde(default)]
1236    pub configured: bool,
1237}
1238
1239impl Default for NtfyChannelConfig {
1240    fn default() -> Self {
1241        Self {
1242            enabled: false,
1243            base_url: default_ntfy_base_url(),
1244            topic: String::new(),
1245            token: None,
1246            token_encrypted: None,
1247            credential_ref: None,
1248            configured: false,
1249        }
1250    }
1251}
1252
1253fn default_ntfy_base_url() -> String {
1254    "https://ntfy.sh".to_string()
1255}
1256
1257/// [Bark](https://github.com/Finb/Bark) iOS push notification channel.
1258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1259pub struct BarkChannelConfig {
1260    #[serde(default)]
1261    pub enabled: bool,
1262    /// Bark server base URL (public api.day.app or a self-hosted instance).
1263    #[serde(default = "default_bark_base_url")]
1264    pub base_url: String,
1265    /// Bark device key identifying the target iOS device.
1266    ///
1267    /// Secret plaintext hydrated in memory from the isolated credential store;
1268    /// never serialized in ordinary config.
1269    #[serde(default, skip_serializing)]
1270    pub device_key: Option<String>,
1271    /// Legacy encrypted ciphertext accepted only for migration.
1272    #[serde(default, skip_serializing)]
1273    pub device_key_encrypted: Option<String>,
1274    /// Stable isolated credential-store reference.
1275    #[serde(default, skip_serializing_if = "Option::is_none")]
1276    pub credential_ref: Option<crate::CredentialRef>,
1277    /// Whether the referenced credential is expected to exist.
1278    #[serde(default)]
1279    pub configured: bool,
1280}
1281
1282impl Default for BarkChannelConfig {
1283    fn default() -> Self {
1284        Self {
1285            enabled: false,
1286            base_url: default_bark_base_url(),
1287            device_key: None,
1288            device_key_encrypted: None,
1289            credential_ref: None,
1290            configured: false,
1291        }
1292    }
1293}
1294
1295fn default_bark_base_url() -> String {
1296    "https://api.day.app".to_string()
1297}
1298
1299/// Notification delivery channels: native desktop plus push-relay services.
1300///
1301/// Additive/back-compat: an absent `notifications` key in `config.json`
1302/// deserializes to the defaults (desktop auto, ntfy/bark disabled).
1303#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1304pub struct NotificationsConfig {
1305    #[serde(default)]
1306    pub desktop: DesktopChannelConfig,
1307    #[serde(default)]
1308    pub ntfy: NtfyChannelConfig,
1309    #[serde(default)]
1310    pub bark: BarkChannelConfig,
1311}
1312
1313/// One IM-platform bridge configured under `[[connect.platforms]]` —
1314/// bamboo-connect (issue #452 / epic #447): drives a bamboo session from an
1315/// external chat platform (Telegram first).
1316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1317pub struct ConnectPlatformConfig {
1318    /// Stable per-entry identifier (#496). Not part of the original schema —
1319    /// absent on legacy/hand-written entries and on a freshly-echoed new
1320    /// entry from a client. [`Config::save_to_dir`] assigns one (a random
1321    /// UUID) to any entry that lacks it as part of the normal save path
1322    /// (migration-on-write); load never mutates/rewrites the config to
1323    /// backfill it, per #493's never-overwrite-until-confirmed semantics.
1324    ///
1325    /// Used by [`crate::patch::preserve_masked_connect_secrets`] as the
1326    /// FIRST resolution strategy for a masked secret in a settings PATCH,
1327    /// ahead of the positional/`type`-based fallbacks (#490/#492) — an exact
1328    /// id match unambiguously identifies the same logical entry even when
1329    /// two entries share the same `platform_type` and have been reordered,
1330    /// which position+type alone cannot always disambiguate.
1331    #[serde(default, skip_serializing_if = "Option::is_none")]
1332    pub id: Option<String>,
1333    /// Optional first-class Project id assigned to newly-created sessions from
1334    /// this connector. Existing connector sessions keep their persisted
1335    /// membership when configuration changes.
1336    #[serde(default, skip_serializing_if = "Option::is_none")]
1337    pub project_id: Option<bamboo_domain::ProjectId>,
1338    /// Platform adapter selector, e.g. `"telegram"`. Unrecognized values are
1339    /// skipped (with a startup warning) rather than failing config load —
1340    /// forward-compatible with future adapters (Feishu/Slack).
1341    #[serde(rename = "type")]
1342    pub platform_type: String,
1343    /// Platform bot/API token.
1344    ///
1345    /// Secret: encrypted at rest in `token_encrypted`; this plaintext field is
1346    /// never serialized and is hydrated in memory on load (mirrors
1347    /// [`NtfyChannelConfig::token`] / [`BarkChannelConfig::device_key`]).
1348    #[serde(default, skip_serializing)]
1349    pub token: Option<String>,
1350    /// Encrypted ciphertext of `token` (the at-rest representation).
1351    #[serde(default, skip_serializing_if = "Option::is_none")]
1352    pub token_encrypted: Option<String>,
1353    /// Stable reference to the isolated token credential.
1354    #[serde(default, skip_serializing_if = "Option::is_none")]
1355    pub token_credential_ref: Option<crate::CredentialRef>,
1356    /// Durable metadata used by redacted clients without exposing a value.
1357    #[serde(default)]
1358    pub token_configured: bool,
1359    /// Platform app id (Feishu `app_id`). Not a secret — serialized normally.
1360    /// Unused by the Telegram adapter.
1361    #[serde(default, skip_serializing_if = "Option::is_none")]
1362    pub app_id: Option<String>,
1363    /// Platform app secret (Feishu `app_secret`).
1364    ///
1365    /// Secret: encrypted at rest in `app_secret_encrypted`; this plaintext
1366    /// field is never serialized and is hydrated in memory on load (mirrors
1367    /// `token` above).
1368    #[serde(default, skip_serializing)]
1369    pub app_secret: Option<String>,
1370    /// Encrypted ciphertext of `app_secret` (the at-rest representation).
1371    #[serde(default, skip_serializing_if = "Option::is_none")]
1372    pub app_secret_encrypted: Option<String>,
1373    /// Stable reference to the isolated app-secret credential.
1374    #[serde(default, skip_serializing_if = "Option::is_none")]
1375    pub app_secret_credential_ref: Option<crate::CredentialRef>,
1376    /// Durable metadata used by redacted clients without exposing a value.
1377    #[serde(default)]
1378    pub app_secret_configured: bool,
1379    /// Platform domain/base-URL selector (Feishu-only today). Not a secret —
1380    /// serialized normally. `None`/`"feishu"` -> open.feishu.cn, `"lark"` ->
1381    /// open.larksuite.com, an `https://` value -> a private-deployment base
1382    /// URL used verbatim. Validation happens in the server registration arm,
1383    /// not here.
1384    #[serde(default, skip_serializing_if = "Option::is_none")]
1385    pub domain: Option<String>,
1386    /// Platform-scoped user ids allowed to drive a session. Deliberately
1387    /// STRICTER than the general secret-mask precedents: an EMPTY list means
1388    /// deny-all (every inbound message is rejected), not allow-all — a
1389    /// startup warning is logged when a platform has no allowed users.
1390    #[serde(default)]
1391    pub allow_from: Vec<String>,
1392    /// User ids allowed to run privileged/admin commands. Parsed from day one
1393    /// but UNUSED in the MVP (#452) — no admin commands exist yet; reserved
1394    /// for the approvals/admin phase of epic #447.
1395    #[serde(default)]
1396    pub admin_from: Vec<String>,
1397}
1398
1399/// bamboo-connect platform bridges: drive bamboo sessions from IM platforms.
1400/// Additive/back-compat: an absent `connect` key in `config.json`
1401/// deserializes to an empty platform list — fully inert (#452).
1402#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1403pub struct ConnectConfig {
1404    #[serde(default)]
1405    pub platforms: Vec<ConnectPlatformConfig>,
1406}
1407
1408fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1409    config.platforms.is_empty()
1410}
1411
1412/// One publisher key trusted to sign plugin bundles.
1413///
1414/// `algorithm` is a plain string (not an enum) so an unrecognized future value
1415/// in an old/new config just never matches during verification rather than
1416/// failing to deserialize — additive/forward-compatible, matching this
1417/// crate's other config sections. Only `"ed25519"` is understood today.
1418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1419pub struct TrustedKey {
1420    /// Human-readable label (surfaced in logs/CLI output); purely descriptive.
1421    pub label: String,
1422    /// Signature algorithm. Only `"ed25519"` is currently verified.
1423    pub algorithm: String,
1424    /// Hex-encoded public key (32 raw bytes for ed25519).
1425    pub public_key: String,
1426}
1427
1428/// Raw Ed25519 public anchor committed by the Jiandu plugin publisher.
1429pub const JIANDU_PLUGIN_PUBLIC_KEY: &str =
1430    "bba9d0e5553401125f939bc3d844e891706d579a611fea9215545771d5306676";
1431
1432/// Default trusted host+path prefix: the `bigduu` GitHub org/user's own repos
1433/// (e.g. `github.com/bigduu/Nova/releases/...`).
1434pub(crate) fn default_trusted_hosts() -> Vec<String> {
1435    vec!["github.com/bigduu/".to_string()]
1436}
1437
1438pub use crate::plugin_trust::{PluginTrustConfig, PublisherMode};
1439
1440/// `plugin_trust.enforcement`: the persistent, config-level form of the
1441/// `--insecure` escape hatch (see [`PluginTrustConfig::enforcement`]).
1442///
1443/// Deserialization accepts either the canonical string form (`"strict"` /
1444/// `"off"`, case-insensitive) or a bool-ish alias (`true` == `Strict`,
1445/// `false` == `Off`) for a hand-edited `config.json` — `true`/`false` read
1446/// naturally as "is enforcement on?". The string form is what
1447/// `bamboo config set plugin_trust.enforcement off` writes (and the only
1448/// form the generic dot-path setter's round-trip check accepts on write,
1449/// since this type always *serializes* back out as a string — see
1450/// `bamboo-config`'s `dot_path` module); the bool alias is a read-side
1451/// convenience for whoever edits `config.json` directly.
1452#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1453#[serde(rename_all = "snake_case")]
1454pub enum PluginTrustEnforcement {
1455    /// Secure by default: the host allowlist, signature, and checksum layers
1456    /// are all enforced (each individually waivable via
1457    /// `--allow-untrusted-host` / `--allow-unsigned` / `--allow-unverified`,
1458    /// or all at once via `--insecure`).
1459    #[default]
1460    Strict,
1461    /// Every `url` plugin install/update skips all three trust layers,
1462    /// without needing any per-install flag. Opt-in only — never the
1463    /// default for a fresh or pre-existing config.
1464    Off,
1465}
1466
1467impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1468    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1469    where
1470        D: serde::Deserializer<'de>,
1471    {
1472        #[derive(Deserialize)]
1473        #[serde(untagged)]
1474        enum Repr {
1475            Bool(bool),
1476            Str(String),
1477        }
1478        match Repr::deserialize(deserializer)? {
1479            Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1480            Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1481            Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1482                "strict" => Ok(PluginTrustEnforcement::Strict),
1483                "off" => Ok(PluginTrustEnforcement::Off),
1484                other => Err(serde::de::Error::custom(format!(
1485                    "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1486                     \"off\""
1487                ))),
1488            },
1489        }
1490    }
1491}
1492
1493/// One `trusted_hosts` entry, split into its host and path-prefix
1494/// components (see [`is_host_trusted`]). `path_prefix` is empty for a
1495/// bare-host entry (e.g. `"example.com"`, meaning "any path on this exact
1496/// host") or starts with `/` (e.g. `"/bigduu/"` from `"github.com/bigduu/"`).
1497struct TrustedHostEntry<'a> {
1498    host: &'a str,
1499    path_prefix: &'a str,
1500}
1501
1502/// Split a raw `trusted_hosts` entry at its first `/` into host + path
1503/// components. Entries are compared against ALREADY-lowercased input by the
1504/// caller ([`is_host_trusted`]), so this does no case normalization itself.
1505fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1506    match entry.find('/') {
1507        Some(index) => TrustedHostEntry {
1508            host: &entry[..index],
1509            path_prefix: &entry[index..],
1510        },
1511        None => TrustedHostEntry {
1512            host: entry,
1513            path_prefix: "",
1514        },
1515    }
1516}
1517
1518/// True when `path` matches `prefix` on a `/` path-component boundary, never
1519/// on a raw byte prefix: exactly equal to `prefix`, or `prefix` ends in `/`
1520/// and `path` starts with it, or the character in `path` immediately
1521/// following `prefix` is `/`. An empty `prefix` (a bare-host trusted_hosts
1522/// entry) matches any path.
1523///
1524/// This is what stops a sibling path from passing as a prefix match — e.g.
1525/// entry `github.com/bigduu` (no trailing slash) must NOT match
1526/// `github.com/bigduu-evil/x`: `"/bigduu-evil/x"` starts with `"/bigduu"` as
1527/// raw bytes, but the character right after the prefix is `-`, not `/`, so
1528/// this correctly refuses it.
1529fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1530    if prefix.is_empty() || path == prefix {
1531        return true;
1532    }
1533    if prefix.ends_with('/') {
1534        return path.starts_with(prefix);
1535    }
1536    path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1537}
1538
1539/// Free function backing [`PluginTrustConfig::is_host_trusted`] — exposed
1540/// separately so callers (and tests) can check a candidate host list without
1541/// constructing a full [`PluginTrustConfig`]/[`Config`].
1542///
1543/// Matches on PARSED URL COMPONENTS, not a raw string prefix: `url` must be
1544/// `https`, its `host_str()` (already correct for userinfo — `user@host` or
1545/// `host@evil.com`-style tricks resolve to the real host, not a decoy — and
1546/// for an explicit port, which `host_str()` excludes) must EQUAL a
1547/// `trusted_hosts` entry's host component (case-insensitively, WHOLE host —
1548/// never a `starts_with`), and its (already dot-segment-normalized by
1549/// `Url::parse`) path must match that entry's path-prefix component on a `/`
1550/// boundary (see [`path_matches_prefix`]). A bare-host entry (no `/` in it)
1551/// has an empty path-prefix, so it matches any path but ONLY on that exact
1552/// host.
1553///
1554/// A prior raw-`starts_with` implementation was defeated by (1) gluing a
1555/// trusted bare host into a longer attacker-controlled one, e.g.
1556/// `trusted.example.com` matching `trusted.example.com.evil.com` /
1557/// `trusted.example.comevil.com`, and (2) a sibling path prefix, e.g.
1558/// `github.com/bigduu` (no trailing slash) matching
1559/// `github.com/bigduu-evil/x`. Component-wise matching closes both: host
1560/// comparison is whole-string equality (no gluing possible), and the path
1561/// check enforces a `/` boundary (no sibling-prefix bypass possible).
1562pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1563    let Ok(parsed) = url::Url::parse(url) else {
1564        return false;
1565    };
1566    if parsed.scheme() != "https" {
1567        return false;
1568    }
1569    let Some(host) = parsed.host_str() else {
1570        return false;
1571    };
1572    let host = host.to_ascii_lowercase();
1573    let path = parsed.path();
1574
1575    trusted_hosts.iter().any(|raw_entry| {
1576        let entry = raw_entry.trim().to_ascii_lowercase();
1577        let parsed_entry = parse_trusted_host_entry(&entry);
1578        host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1579    })
1580}
1581
1582/// Host strategy used when the active model context approaches its input limit.
1583///
1584/// `Summary` remains the compatibility default. `MessageSummary` summarizes only
1585/// historical user/assistant prose and removes completed tool exchanges from the
1586/// active model view. `RetrievalWindow` is opt-in and
1587/// archives old exact messages only after the runtime has verified that the
1588/// current-session history capability is callable and the boundary can be
1589/// durably checkpointed.
1590#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1591#[serde(rename_all = "snake_case")]
1592pub enum ContextManagementStrategy {
1593    #[default]
1594    Summary,
1595    MessageSummary,
1596    RetrievalWindow,
1597}
1598
1599/// Explicit fallback used when retrieval-window cannot satisfy a runtime
1600/// precondition. There is deliberately no implicit summary fallback.
1601#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1602#[serde(rename_all = "snake_case")]
1603pub enum ContextManagementFallbackStrategy {
1604    #[default]
1605    None,
1606    Summary,
1607}
1608
1609fn default_retrieval_window_min_recent_user_turns() -> usize {
1610    3
1611}
1612
1613fn default_context_trigger_usage_ratio() -> f64 {
1614    0.80
1615}
1616
1617fn default_context_target_usage_ratio() -> f64 {
1618    0.25
1619}
1620
1621fn default_retrieval_window_trigger_usage_ratio() -> f64 {
1622    0.80
1623}
1624
1625fn default_retrieval_window_target_usage_ratio() -> f64 {
1626    0.60
1627}
1628
1629fn default_history_tool_required() -> bool {
1630    true
1631}
1632
1633/// Selection and safety policy for the opt-in retrieval-window strategy.
1634#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1635#[serde(default)]
1636pub struct RetrievalWindowContextConfig {
1637    #[serde(default = "default_retrieval_window_min_recent_user_turns")]
1638    pub min_recent_user_turns: usize,
1639    #[serde(default = "default_retrieval_window_trigger_usage_ratio")]
1640    pub trigger_usage_ratio: f64,
1641    #[serde(default = "default_retrieval_window_target_usage_ratio")]
1642    pub target_usage_ratio: f64,
1643    #[serde(default = "default_history_tool_required")]
1644    pub history_tool_required: bool,
1645    #[serde(default)]
1646    pub fallback_strategy: ContextManagementFallbackStrategy,
1647}
1648
1649impl Default for RetrievalWindowContextConfig {
1650    fn default() -> Self {
1651        Self {
1652            min_recent_user_turns: default_retrieval_window_min_recent_user_turns(),
1653            trigger_usage_ratio: default_retrieval_window_trigger_usage_ratio(),
1654            target_usage_ratio: default_retrieval_window_target_usage_ratio(),
1655            history_tool_required: default_history_tool_required(),
1656            fallback_strategy: ContextManagementFallbackStrategy::None,
1657        }
1658    }
1659}
1660
1661/// Backward-compatible context-management configuration snapshot.
1662#[derive(Debug, Clone, Serialize, PartialEq)]
1663pub struct ContextManagementConfig {
1664    pub strategy: ContextManagementStrategy,
1665    /// Shared upper bound for proactive compression across every strategy.
1666    /// The effective token boundary can be earlier after output/safety reserves.
1667    pub trigger_usage_ratio: f64,
1668    /// Shared post-compression target relative to the model's complete window.
1669    pub target_usage_ratio: f64,
1670    pub retrieval_window: RetrievalWindowContextConfig,
1671}
1672
1673impl Default for ContextManagementConfig {
1674    fn default() -> Self {
1675        Self {
1676            strategy: ContextManagementStrategy::Summary,
1677            trigger_usage_ratio: default_context_trigger_usage_ratio(),
1678            target_usage_ratio: default_context_target_usage_ratio(),
1679            retrieval_window: RetrievalWindowContextConfig::default(),
1680        }
1681    }
1682}
1683
1684impl ContextManagementConfig {
1685    pub fn is_default(&self) -> bool {
1686        *self == Self::default()
1687    }
1688
1689    pub fn validate(&self) -> std::result::Result<(), String> {
1690        if !self.target_usage_ratio.is_finite()
1691            || !self.trigger_usage_ratio.is_finite()
1692            || self.target_usage_ratio < 0.01
1693            || self.target_usage_ratio >= self.trigger_usage_ratio
1694            || self.trigger_usage_ratio > 1.0
1695            || (self.target_usage_ratio * 100.0).floor()
1696                >= (self.trigger_usage_ratio * 100.0).floor()
1697        {
1698            return Err(
1699                "context_management ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1 with distinct whole-percent boundaries"
1700                    .to_string(),
1701            );
1702        }
1703        let policy = &self.retrieval_window;
1704        if policy.min_recent_user_turns == 0 {
1705            return Err(
1706                "context_management.retrieval_window.min_recent_user_turns must be greater than zero"
1707                    .to_string(),
1708            );
1709        }
1710        if !policy.target_usage_ratio.is_finite()
1711            || !policy.trigger_usage_ratio.is_finite()
1712            || policy.target_usage_ratio < 0.01
1713            || policy.target_usage_ratio >= policy.trigger_usage_ratio
1714            || policy.trigger_usage_ratio > 1.0
1715        {
1716            return Err(
1717                "context_management retrieval ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1"
1718                    .to_string(),
1719            );
1720        }
1721        if self.strategy == ContextManagementStrategy::RetrievalWindow
1722            && !policy.history_tool_required
1723        {
1724            return Err(
1725                "retrieval_window requires history_tool_required=true in this release".to_string(),
1726            );
1727        }
1728        Ok(())
1729    }
1730
1731    /// Resolve the common policy onto a runtime-only budget snapshot. Provider
1732    /// limits and durable session overrides remain unchanged; only maintenance
1733    /// ratios are shared, independent of the selected history representation.
1734    pub fn apply_compression_policy(
1735        &self,
1736        mut budget: bamboo_domain::TokenBudget,
1737    ) -> bamboo_domain::TokenBudget {
1738        budget.compression_trigger_percent = (self.trigger_usage_ratio * 100.0).floor() as u8;
1739        budget.compression_target_percent = (self.target_usage_ratio * 100.0).floor() as u8;
1740        budget
1741    }
1742
1743    /// Compatibility accessor. New runtime paths use the dynamically resolved
1744    /// budget's target percentage, also for retrieval archive receipts.
1745    pub fn retrieval_target_usage_percent(&self) -> u8 {
1746        ((self.target_usage_ratio * 100.0).floor() as u8).max(1)
1747    }
1748}
1749
1750impl<'de> Deserialize<'de> for ContextManagementConfig {
1751    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1752    where
1753        D: serde::Deserializer<'de>,
1754    {
1755        #[derive(Default, Deserialize)]
1756        #[serde(default)]
1757        struct Wire {
1758            strategy: ContextManagementStrategy,
1759            trigger_usage_ratio: Option<f64>,
1760            target_usage_ratio: Option<f64>,
1761            retrieval_window: Option<RetrievalWindowContextConfig>,
1762        }
1763
1764        let wire = Wire::deserialize(deserializer)?;
1765        let retrieval_window = wire.retrieval_window.unwrap_or_default();
1766        // Older clients stored ratios under retrieval_window. Preserve stricter
1767        // settings as common limits, while the old default 80/60 adopts 80/25.
1768        // Explicit common fields take precedence on subsequent saves.
1769        let config = Self {
1770            strategy: wire.strategy,
1771            trigger_usage_ratio: wire.trigger_usage_ratio.unwrap_or_else(|| {
1772                default_context_trigger_usage_ratio().min(retrieval_window.trigger_usage_ratio)
1773            }),
1774            target_usage_ratio: wire.target_usage_ratio.unwrap_or_else(|| {
1775                default_context_target_usage_ratio().min(retrieval_window.target_usage_ratio)
1776            }),
1777            retrieval_window,
1778        };
1779        config.validate().map_err(serde::de::Error::custom)?;
1780        Ok(config)
1781    }
1782}
1783
1784/// Protocol whose model-visible input can be metered locally.
1785#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1786#[serde(rename_all = "snake_case")]
1787pub enum PatrolModelProtocol {
1788    LlamaCppRawV1,
1789}
1790
1791/// Independent selection for semantic patrol preparation. Absent disables it.
1792#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1793#[serde(deny_unknown_fields)]
1794pub struct PatrolModelConfig {
1795    pub provider: String,
1796    pub model: String,
1797    pub protocol: PatrolModelProtocol,
1798    /// Explicit local provider activation. Omitted keeps prompt preparation
1799    /// available without enabling outbound model calls.
1800    #[serde(default, skip_serializing_if = "Option::is_none")]
1801    pub endpoint: Option<std::net::SocketAddr>,
1802    pub max_input_tokens: std::num::NonZeroU32,
1803    pub max_output_tokens: std::num::NonZeroU32,
1804    pub max_evidence_bytes: std::num::NonZeroUsize,
1805    pub max_evidence_refs: std::num::NonZeroUsize,
1806}
1807
1808impl PatrolModelConfig {
1809    pub fn validate(&self) -> Result<(), String> {
1810        if self.provider.is_empty()
1811            || self.provider.len() > 256
1812            || self
1813                .provider
1814                .chars()
1815                .any(|ch| ch.is_whitespace() || ch.is_control())
1816        {
1817            return Err("patrol_model requires an explicit provider identifier".into());
1818        }
1819        if self.max_input_tokens.get() > MAX_INPUT_TOKENS
1820            || self.max_output_tokens.get() > MAX_OUTPUT_TOKENS
1821            || self.max_evidence_bytes.get() > MAX_EVIDENCE_BYTES
1822            || self.max_evidence_refs.get() > MAX_EVIDENCE_REFS
1823        {
1824            return Err(format!(
1825                "patrol_model limits must be at most {MAX_INPUT_TOKENS} input tokens, \
1826                 {MAX_OUTPUT_TOKENS} output tokens, {MAX_EVIDENCE_BYTES} evidence bytes, \
1827                 and {MAX_EVIDENCE_REFS} evidence references"
1828            ));
1829        }
1830        if self
1831            .endpoint
1832            .is_some_and(|endpoint| !endpoint.ip().is_loopback() || endpoint.port() == 0)
1833        {
1834            return Err(
1835                "patrol_model endpoint requires a numeric loopback address and port".into(),
1836            );
1837        }
1838        match self.protocol {
1839            PatrolModelProtocol::LlamaCppRawV1 if self.model == "gpt-oss-20b" => Ok(()),
1840            PatrolModelProtocol::LlamaCppRawV1 => {
1841                Err("llama_cpp_raw_v1 requires the gpt-oss-20b patrol model".into())
1842            }
1843        }
1844    }
1845}
1846
1847/// Main configuration structure for Bamboo agent
1848///
1849/// Contains all settings needed to run the agent, including provider credentials,
1850/// proxy settings, model selection, and server configuration.
1851#[derive(Debug, Clone, Serialize, Deserialize)]
1852#[doc(hidden)]
1853pub struct ConfigValues {
1854    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
1855    #[serde(default)]
1856    pub http_proxy: String,
1857    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
1858    #[serde(default)]
1859    pub https_proxy: String,
1860    /// Proxy authentication credentials
1861    ///
1862    /// Kept in memory only; ordinary config stores `proxy_auth_credential_ref`.
1863    #[serde(skip_serializing)]
1864    pub proxy_auth: Option<ProxyAuth>,
1865    /// Legacy encrypted proxy authentication accepted only for migration.
1866    #[serde(default, skip_serializing_if = "Option::is_none")]
1867    pub proxy_auth_encrypted: Option<String>,
1868    /// Stable credential-store reference for proxy authentication.
1869    #[serde(default, skip_serializing_if = "Option::is_none")]
1870    pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1871    /// Deprecated: Use `providers.copilot.headless_auth` instead
1872    #[serde(default)]
1873    pub headless_auth: bool,
1874
1875    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
1876    #[serde(default = "default_provider")]
1877    pub provider: String,
1878
1879    /// Default model assignments (used when features.provider_model_ref is enabled).
1880    #[serde(default, skip_serializing_if = "Option::is_none")]
1881    pub defaults: Option<DefaultsConfig>,
1882
1883    /// Multi-instance provider configurations keyed by instance id.
1884    ///
1885    /// When `provider_instances` is non-empty, the registry and router prefer
1886    /// instance ids as routing keys. Legacy `providers` / `provider` fields are
1887    /// still supported for backward compatibility; see
1888    /// [`Config::synthesize_legacy_instances`].
1889    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1890    pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1891
1892    /// The default provider instance id used when a request does not specify one.
1893    ///
1894    /// When set, this takes precedence over the legacy `provider` field.
1895    #[serde(default, skip_serializing_if = "Option::is_none")]
1896    pub default_provider_instance: Option<String>,
1897
1898    /// HTTP server configuration
1899    #[serde(default)]
1900    pub server: ServerConfig,
1901
1902    /// Global keyword masking configuration.
1903    ///
1904    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
1905    #[serde(default)]
1906    pub keyword_masking: KeywordMaskingConfig,
1907
1908    /// Anthropic model mapping configuration.
1909    ///
1910    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
1911    #[serde(default)]
1912    pub anthropic_model_mapping: AnthropicModelMapping,
1913
1914    /// Gemini model mapping configuration.
1915    ///
1916    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
1917    #[serde(default)]
1918    pub gemini_model_mapping: GeminiModelMapping,
1919
1920    /// Explicit model selection for offline semantic patrol input preparation.
1921    #[serde(default, skip_serializing_if = "Option::is_none")]
1922    pub patrol_model: Option<PatrolModelConfig>,
1923
1924    /// Request preflight hooks.
1925    ///
1926    /// These hooks can inspect and rewrite outgoing requests before they are sent upstream
1927    /// (e.g. image fallback behavior for text-only models).
1928    #[serde(default)]
1929    pub hooks: HooksConfig,
1930
1931    /// User-configured agent lifecycle command or external script handlers.
1932    ///
1933    /// This is intentionally separate from `hooks`, which is already the
1934    /// provider HTTP request-hook namespace. Lifecycle hooks are snapshotted
1935    /// when an agent run starts.
1936    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1937    pub lifecycle_hooks: LifecycleHooksConfig,
1938
1939    /// Global tool toggles.
1940    ///
1941    /// Any tool listed in `disabled` is omitted from the tool schemas sent to the LLM.
1942    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1943    pub tools: ToolsConfig,
1944
1945    /// Global skill toggles.
1946    ///
1947    /// Any skill listed in `disabled` is excluded from skill context construction and
1948    /// cannot be loaded through the skill runtime tools.
1949    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1950    pub skills: SkillsConfig,
1951
1952    /// User-managed environment variables injected into Bash tool processes.
1953    ///
1954    /// Secret entries are encrypted at rest; plaintext values are hydrated in memory.
1955    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1956    pub env_vars: Vec<EnvVarEntry>,
1957
1958    /// Default work area used when a session has no explicit active workspace.
1959    #[serde(default, skip_serializing_if = "Option::is_none")]
1960    pub default_work_area: Option<DefaultWorkAreaConfig>,
1961
1962    #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
1963    pub worktrees: WorktreeSettings,
1964
1965    /// Access control / password gate configuration.
1966    #[serde(default, skip_serializing_if = "Option::is_none")]
1967    pub access_control: Option<AccessControlConfig>,
1968
1969    /// Feature flags for incremental rollout.
1970    #[serde(default)]
1971    pub features: FeatureFlags,
1972
1973    /// Config-level default per-run token/tool-call/subagent budget (issue
1974    /// #221). `None` fields are unlimited. A per-request `ExecuteRequest`
1975    /// override may only tighten these ceilings, never loosen them; see
1976    /// [`RunBudgetConfig::merged_with_override`].
1977    #[serde(default)]
1978    pub run_budget: RunBudgetConfig,
1979
1980    /// LLM stream liveness and semantic-progress watchdog policy.
1981    #[serde(default)]
1982    pub stream_timeout: StreamTimeoutConfig,
1983
1984    /// Initial-request retry policy for transient transport failures and
1985    /// retryable upstream statuses. Operator-tunable so a flaky or
1986    /// heavily-queued relay can be tolerated without an environment variable
1987    /// and a process restart.
1988    ///
1989    /// `None` means "not configured": the environment-derived policy stays in
1990    /// force, so upgrading a deployment that tunes `BAMBOO_LLM_RETRY_*` does
1991    /// not silently replace those settings with the built-in defaults.
1992    #[serde(default, skip_serializing_if = "Option::is_none")]
1993    pub llm_retry: Option<LlmRetryConfig>,
1994
1995    /// Host-owned strategy for bounding provider-visible conversation context.
1996    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
1997    pub context_management: ContextManagementConfig,
1998
1999    /// Remote Cluster Fabric: operator-managed nodes & clusters for deploying
2000    /// `broker-agent` workers locally or over SSH. Additive/back-compat: absent
2001    /// ⇒ empty. SSH secrets are encrypted at rest (see [`crate::cluster_fabric`]).
2002    #[serde(
2003        default,
2004        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2005    )]
2006    pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2007
2008    /// MCP server configuration.
2009    ///
2010    /// Previously persisted in `mcp.json` (now unified into `config.json`).
2011    // On disk we use the mainstream `mcpServers` key (matching Claude Desktop / MCP ecosystem
2012    // conventions). We still accept the legacy `mcp` key for backward compatibility.
2013    #[serde(default, rename = "mcpServers", alias = "mcp")]
2014    pub mcp: bamboo_domain::mcp_config::McpConfig,
2015
2016    /// Notification delivery channels (desktop + push-relay services).
2017    /// Secrets (ntfy token, Bark device key) live in the isolated credential
2018    /// store; only stable references/configured metadata persist here.
2019    #[serde(default)]
2020    pub notifications: NotificationsConfig,
2021
2022    /// bamboo-connect IM-platform bridges (Telegram first, #452 / epic #447).
2023    /// Secrets (each platform's `token`) are encrypted at rest — see
2024    /// [`Config::hydrate_connect_platform_tokens_from_encrypted`] /
2025    /// [`Config::refresh_connect_platform_tokens_encrypted`].
2026    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2027    pub connect: ConnectConfig,
2028
2029    /// Plugin URL-install source-trust policy (host allowlist + ed25519
2030    /// publisher keys). See [`PluginTrustConfig`]'s docs for the three-layer
2031    /// model this stacks with the checksum layer.
2032    #[serde(default)]
2033    pub plugin_trust: PluginTrustConfig,
2034
2035    /// Extension fields stored at the root of `config.json`.
2036    ///
2037    /// This keeps the config forward-compatible and allows unrelated subsystems
2038    /// (e.g. setup UI state) to persist their own keys without getting dropped by
2039    /// typed (de)serialization.
2040    #[serde(default, flatten)]
2041    pub extra: BTreeMap<String, Value>,
2042}
2043
2044impl Default for ConfigValues {
2045    fn default() -> Self {
2046        Self {
2047            http_proxy: String::new(),
2048            https_proxy: String::new(),
2049            proxy_auth: None,
2050            proxy_auth_encrypted: None,
2051            proxy_auth_credential_ref: None,
2052            headless_auth: false,
2053            run_budget: RunBudgetConfig::default(),
2054            stream_timeout: StreamTimeoutConfig::default(),
2055            llm_retry: None,
2056            context_management: ContextManagementConfig::default(),
2057            cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
2058            provider: default_provider(),
2059            provider_instances: HashMap::new(),
2060            default_provider_instance: None,
2061            server: ServerConfig::default(),
2062            keyword_masking: KeywordMaskingConfig::default(),
2063            anthropic_model_mapping: AnthropicModelMapping::default(),
2064            gemini_model_mapping: GeminiModelMapping::default(),
2065            patrol_model: None,
2066            hooks: HooksConfig::default(),
2067            lifecycle_hooks: LifecycleHooksConfig::default(),
2068            tools: ToolsConfig::default(),
2069            skills: SkillsConfig::default(),
2070            env_vars: Vec::new(),
2071            default_work_area: None,
2072            worktrees: WorktreeSettings::default(),
2073            access_control: None,
2074            features: FeatureFlags::default(),
2075            defaults: None,
2076            mcp: bamboo_domain::mcp_config::McpConfig::default(),
2077            notifications: NotificationsConfig::default(),
2078            connect: ConnectConfig::default(),
2079            plugin_trust: PluginTrustConfig::default(),
2080            extra: BTreeMap::new(),
2081        }
2082    }
2083}
2084
2085/// Network-facing root configuration. Flattening preserves the historical
2086/// top-level JSON keys while making the persisted root structurally modular.
2087#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2088struct NetworkConfigSection {
2089    #[serde(default)]
2090    http_proxy: String,
2091    #[serde(default)]
2092    https_proxy: String,
2093    #[serde(skip_serializing)]
2094    proxy_auth: Option<ProxyAuth>,
2095    #[serde(default, skip_serializing_if = "Option::is_none")]
2096    proxy_auth_encrypted: Option<String>,
2097    #[serde(default, skip_serializing_if = "Option::is_none")]
2098    proxy_auth_credential_ref: Option<crate::CredentialRef>,
2099    #[serde(default)]
2100    headless_auth: bool,
2101    #[serde(default)]
2102    server: ServerConfig,
2103}
2104
2105#[derive(Debug, Clone, Serialize, Deserialize)]
2106struct ProviderRoutingConfigSection {
2107    #[serde(default = "default_provider")]
2108    provider: String,
2109    #[serde(default, skip_serializing_if = "Option::is_none")]
2110    defaults: Option<DefaultsConfig>,
2111    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2112    provider_instances: HashMap<String, ProviderInstanceConfig>,
2113    #[serde(default, skip_serializing_if = "Option::is_none")]
2114    default_provider_instance: Option<String>,
2115}
2116
2117impl Default for ProviderRoutingConfigSection {
2118    fn default() -> Self {
2119        Self {
2120            provider: default_provider(),
2121            defaults: None,
2122            provider_instances: HashMap::new(),
2123            default_provider_instance: None,
2124        }
2125    }
2126}
2127
2128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2129struct ModelBehaviorConfigSection {
2130    #[serde(default)]
2131    keyword_masking: KeywordMaskingConfig,
2132    #[serde(default)]
2133    anthropic_model_mapping: AnthropicModelMapping,
2134    #[serde(default)]
2135    gemini_model_mapping: GeminiModelMapping,
2136    #[serde(default, skip_serializing_if = "Option::is_none")]
2137    patrol_model: Option<PatrolModelConfig>,
2138    #[serde(default)]
2139    hooks: HooksConfig,
2140    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
2141    lifecycle_hooks: LifecycleHooksConfig,
2142}
2143
2144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2145struct ToolingConfigSection {
2146    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
2147    tools: ToolsConfig,
2148    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
2149    skills: SkillsConfig,
2150    #[serde(default, rename = "mcpServers", alias = "mcp")]
2151    mcp: bamboo_domain::mcp_config::McpConfig,
2152}
2153
2154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2155struct WorkspaceConfigSection {
2156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2157    env_vars: Vec<EnvVarEntry>,
2158    #[serde(default, skip_serializing_if = "Option::is_none")]
2159    default_work_area: Option<DefaultWorkAreaConfig>,
2160    #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
2161    worktrees: WorktreeSettings,
2162    #[serde(default, skip_serializing_if = "Option::is_none")]
2163    access_control: Option<AccessControlConfig>,
2164}
2165
2166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2167struct ExecutionConfigSection {
2168    #[serde(default)]
2169    features: FeatureFlags,
2170    #[serde(default)]
2171    run_budget: RunBudgetConfig,
2172    #[serde(default)]
2173    stream_timeout: StreamTimeoutConfig,
2174    #[serde(default, skip_serializing_if = "Option::is_none")]
2175    llm_retry: Option<LlmRetryConfig>,
2176    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2177    context_management: ContextManagementConfig,
2178    #[serde(
2179        default,
2180        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2181    )]
2182    cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2183}
2184
2185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2186struct IntegrationConfigSection {
2187    #[serde(default)]
2188    notifications: NotificationsConfig,
2189    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2190    connect: ConnectConfig,
2191}
2192
2193#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2194struct PluginSecurityConfigSection {
2195    #[serde(default)]
2196    plugin_trust: PluginTrustConfig,
2197}
2198
2199// Count declarations from the same field list that defines each structural
2200// budgeted type, so the constants cannot drift from the actual structs.
2201macro_rules! count_fields {
2202    ($($field:ident),* $(,)?) => {
2203        <[()]>::len(&[$(count_fields!(@one $field)),*])
2204    };
2205    (@one $field:ident) => { () };
2206}
2207
2208macro_rules! define_counted_struct {
2209    (
2210        $(#[$struct_meta:meta])*
2211        $visibility:vis struct $name:ident {
2212            $(
2213                $(#[$field_meta:meta])*
2214                $field_visibility:vis $field:ident: $field_type:ty
2215            ),* $(,)?
2216        }
2217        count $count_visibility:vis $count_name:ident;
2218    ) => {
2219        $(#[$struct_meta])*
2220        $visibility struct $name {
2221            $(
2222                $(#[$field_meta])*
2223                $field_visibility $field: $field_type,
2224            )*
2225        }
2226
2227        $count_visibility const $count_name: usize = count_fields!($($field),*);
2228    };
2229}
2230
2231define_counted_struct! {
2232    /// The root-only persistence DTO written to `config.json`.
2233    ///
2234    /// Every section is flattened so existing documents keep their historical
2235    /// top-level shape. The structural field count is nevertheless nine rather
2236    /// than the Phase-#39 baseline of 31.
2237    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
2238    struct ConfigRoot {
2239        #[serde(flatten)]
2240        network: NetworkConfigSection,
2241        #[serde(flatten)]
2242        provider_routing: ProviderRoutingConfigSection,
2243        #[serde(flatten)]
2244        model_behavior: ModelBehaviorConfigSection,
2245        #[serde(flatten)]
2246        tooling: ToolingConfigSection,
2247        #[serde(flatten)]
2248        workspace: WorkspaceConfigSection,
2249        #[serde(flatten)]
2250        execution: ExecutionConfigSection,
2251        #[serde(flatten)]
2252        integrations: IntegrationConfigSection,
2253        #[serde(flatten)]
2254        plugin_security: PluginSecurityConfigSection,
2255        #[serde(default, flatten)]
2256        extra: BTreeMap<String, Value>,
2257    }
2258    count pub PERSISTED_ROOT_FIELD_COUNT;
2259}
2260
2261impl From<ConfigValues> for ConfigRoot {
2262    fn from(values: ConfigValues) -> Self {
2263        // Deliberately exhaustive: adding a runtime compatibility field must
2264        // update its persisted section mapping or this conversion stops compiling.
2265        let ConfigValues {
2266            http_proxy,
2267            https_proxy,
2268            proxy_auth,
2269            proxy_auth_encrypted,
2270            proxy_auth_credential_ref,
2271            headless_auth,
2272            provider,
2273            defaults,
2274            provider_instances,
2275            default_provider_instance,
2276            server,
2277            keyword_masking,
2278            anthropic_model_mapping,
2279            gemini_model_mapping,
2280            patrol_model,
2281            hooks,
2282            lifecycle_hooks,
2283            tools,
2284            skills,
2285            env_vars,
2286            default_work_area,
2287            worktrees,
2288            access_control,
2289            features,
2290            run_budget,
2291            stream_timeout,
2292            llm_retry,
2293            context_management,
2294            cluster_fabric,
2295            mcp,
2296            notifications,
2297            connect,
2298            plugin_trust,
2299            extra,
2300        } = values;
2301
2302        Self {
2303            network: NetworkConfigSection {
2304                http_proxy,
2305                https_proxy,
2306                proxy_auth,
2307                proxy_auth_encrypted,
2308                proxy_auth_credential_ref,
2309                headless_auth,
2310                server,
2311            },
2312            provider_routing: ProviderRoutingConfigSection {
2313                provider,
2314                defaults,
2315                provider_instances,
2316                default_provider_instance,
2317            },
2318            model_behavior: ModelBehaviorConfigSection {
2319                keyword_masking,
2320                anthropic_model_mapping,
2321                gemini_model_mapping,
2322                patrol_model,
2323                hooks,
2324                lifecycle_hooks,
2325            },
2326            tooling: ToolingConfigSection { tools, skills, mcp },
2327            workspace: WorkspaceConfigSection {
2328                env_vars,
2329                default_work_area,
2330                worktrees,
2331                access_control,
2332            },
2333            execution: ExecutionConfigSection {
2334                features,
2335                run_budget,
2336                stream_timeout,
2337                llm_retry,
2338                context_management,
2339                cluster_fabric,
2340            },
2341            integrations: IntegrationConfigSection {
2342                notifications,
2343                connect,
2344            },
2345            plugin_security: PluginSecurityConfigSection { plugin_trust },
2346            extra,
2347        }
2348    }
2349}
2350
2351impl From<ConfigRoot> for ConfigValues {
2352    fn from(root: ConfigRoot) -> Self {
2353        // Keep every root and section destructure exhaustive so a newly added
2354        // persisted field cannot be silently omitted from the runtime view.
2355        let ConfigRoot {
2356            network,
2357            provider_routing,
2358            model_behavior,
2359            tooling,
2360            workspace,
2361            execution,
2362            integrations,
2363            plugin_security,
2364            extra,
2365        } = root;
2366        let NetworkConfigSection {
2367            http_proxy,
2368            https_proxy,
2369            proxy_auth,
2370            proxy_auth_encrypted,
2371            proxy_auth_credential_ref,
2372            headless_auth,
2373            server,
2374        } = network;
2375        let ProviderRoutingConfigSection {
2376            provider,
2377            defaults,
2378            provider_instances,
2379            default_provider_instance,
2380        } = provider_routing;
2381        let ModelBehaviorConfigSection {
2382            keyword_masking,
2383            anthropic_model_mapping,
2384            gemini_model_mapping,
2385            patrol_model,
2386            hooks,
2387            lifecycle_hooks,
2388        } = model_behavior;
2389        let ToolingConfigSection { tools, skills, mcp } = tooling;
2390        let WorkspaceConfigSection {
2391            env_vars,
2392            default_work_area,
2393            worktrees,
2394            access_control,
2395        } = workspace;
2396        let ExecutionConfigSection {
2397            features,
2398            run_budget,
2399            stream_timeout,
2400            llm_retry,
2401            context_management,
2402            cluster_fabric,
2403        } = execution;
2404        let IntegrationConfigSection {
2405            notifications,
2406            connect,
2407        } = integrations;
2408        let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2409
2410        Self {
2411            http_proxy,
2412            https_proxy,
2413            proxy_auth,
2414            proxy_auth_encrypted,
2415            proxy_auth_credential_ref,
2416            headless_auth,
2417            provider,
2418            defaults,
2419            provider_instances,
2420            default_provider_instance,
2421            server,
2422            keyword_masking,
2423            anthropic_model_mapping,
2424            gemini_model_mapping,
2425            patrol_model,
2426            hooks,
2427            lifecycle_hooks,
2428            tools,
2429            skills,
2430            env_vars,
2431            default_work_area,
2432            worktrees,
2433            access_control,
2434            features,
2435            run_budget,
2436            stream_timeout,
2437            llm_retry,
2438            context_management,
2439            cluster_fabric,
2440            mcp,
2441            notifications,
2442            connect,
2443            plugin_trust,
2444            extra,
2445        }
2446    }
2447}
2448
2449/// Serialize the root-only durable document.
2450///
2451/// Public `Config` serialization intentionally retains the historical
2452/// compatibility shape, including the legacy `provider` selector. Durable
2453/// instance-native writes are narrower: the explicit instance default is the
2454/// routing authority, so legacy routing fields must not be written back.
2455fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2456    let instance_native = values
2457        .default_provider_instance
2458        .as_ref()
2459        .is_some_and(|id| values.provider_instances.contains_key(id));
2460    let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2461    if instance_native {
2462        if let Some(object) = value.as_object_mut() {
2463            object.remove("provider");
2464            object.remove("providers");
2465        }
2466    }
2467    Ok(value)
2468}
2469
2470define_counted_struct! {
2471    /// Runtime configuration facade. Phase-1 sidecar domains are typed modules;
2472    /// the remaining values keep field-access compatibility through `Deref`.
2473    #[derive(Debug, Clone)]
2474    pub struct Config {
2475        values: ConfigValues,
2476        pub(crate) memory: crate::MemoryConfigModule,
2477        pub(crate) subagents: crate::SubagentsConfigModule,
2478        pub(crate) providers: crate::ProviderConfigsModule,
2479        recovery_status: Option<ConfigRecoveryStatus>,
2480    }
2481    count pub CONFIG_FIELD_COUNT;
2482}
2483
2484/// Auditable structural budgets from Issue #590.
2485pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2486const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2487const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2488
2489impl std::ops::Deref for Config {
2490    type Target = ConfigValues;
2491
2492    fn deref(&self) -> &Self::Target {
2493        &self.values
2494    }
2495}
2496
2497impl std::ops::DerefMut for Config {
2498    fn deref_mut(&mut self) -> &mut Self::Target {
2499        &mut self.values
2500    }
2501}
2502
2503impl Serialize for Config {
2504    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2505    where
2506        S: serde::Serializer,
2507    {
2508        self.to_compatibility_value()
2509            .map_err(serde::ser::Error::custom)?
2510            .serialize(serializer)
2511    }
2512}
2513
2514impl<'de> Deserialize<'de> for Config {
2515    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2516    where
2517        D: serde::Deserializer<'de>,
2518    {
2519        use serde::de::Error;
2520
2521        let mut value = Value::deserialize(deserializer)?;
2522        let object = value
2523            .as_object_mut()
2524            .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2525        let memory = object
2526            .remove("memory")
2527            .map(serde_json::from_value)
2528            .transpose()
2529            .map_err(D::Error::custom)?
2530            .unwrap_or_default();
2531        let subagents = object
2532            .remove("subagents")
2533            .map(serde_json::from_value)
2534            .transpose()
2535            .map_err(D::Error::custom)?
2536            .unwrap_or_default();
2537        let providers = object
2538            .remove("providers")
2539            .map(serde_json::from_value)
2540            .transpose()
2541            .map_err(D::Error::custom)?
2542            .unwrap_or_default();
2543        let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2544        if let Some(model) = &root.model_behavior.patrol_model {
2545            model.validate().map_err(D::Error::custom)?;
2546        }
2547
2548        Ok(Self::from_parts(root.into(), memory, subagents, providers))
2549    }
2550}
2551
2552/// Where a [`ConfigRecoveryStatus`]'s recovered values came from. #153.
2553#[derive(Debug, Clone, PartialEq, Serialize)]
2554#[serde(tag = "kind", rename_all = "snake_case")]
2555pub enum ConfigRecoverySource {
2556    /// Field-by-field salvage from the corrupt file itself
2557    /// ([`Config::salvage_partial`]); `fields` lists the top-level keys that
2558    /// were recovered from the corrupt document (any other field fell back to
2559    /// the backup/default baseline instead).
2560    Salvaged { fields: Vec<String> },
2561    /// Recovered wholesale from a `config.json.bak[.N]` generation
2562    /// (`generation` 0 == `.bak`, 1 == `.bak.1`, …).
2563    Backup { generation: usize },
2564    /// No usable salvage or backup; fell back to built-in defaults.
2565    Defaults,
2566}
2567
2568/// Describes a pending config-corruption recovery (#153, following on from
2569/// #37/#135's quarantine + salvage/backup chain): `config.json` failed to
2570/// parse at load time, the corrupt original was quarantined (copied aside,
2571/// not deleted) to `quarantine_path`, and the owning [`Config`] holds the
2572/// recovered in-memory state instead.
2573///
2574/// [`Config::save_to_dir`] refuses to overwrite `config.json` while
2575/// `confirmed` is `false`, so a user who would rather hand-fix the original
2576/// isn't surprised by an automatic overwrite on the next save. Call
2577/// [`Config::confirm_recovery`] (or [`Config::confirm_recovery_and_save_to_dir`])
2578/// to allow the next save through.
2579#[derive(Debug, Clone, PartialEq, Serialize)]
2580pub struct ConfigRecoveryStatus {
2581    /// Where the recovered values came from.
2582    pub source: ConfigRecoverySource,
2583    /// Absolute path of the preserved copy of the corrupt original
2584    /// (`config.json.corrupted.<nanos>`), or `None` if even the quarantine
2585    /// copy failed (the corrupt original still remains in place at
2586    /// `config.json` itself either way — quarantining copies, it doesn't
2587    /// move — so the guard below still applies).
2588    pub quarantine_path: Option<PathBuf>,
2589    /// Set `true` once the user has explicitly confirmed the recovery; only
2590    /// then may `save_to_dir` persist over the original `config.json`.
2591    pub confirmed: bool,
2592}
2593
2594/// Container for provider-specific configurations
2595///
2596/// Each field is optional, allowing users to configure only the providers they need.
2597#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2598pub struct ProviderConfigs {
2599    /// OpenAI provider configuration
2600    #[serde(skip_serializing_if = "Option::is_none")]
2601    pub openai: Option<OpenAIConfig>,
2602    /// Anthropic provider configuration
2603    #[serde(skip_serializing_if = "Option::is_none")]
2604    pub anthropic: Option<AnthropicConfig>,
2605    /// Google Gemini provider configuration
2606    #[serde(skip_serializing_if = "Option::is_none")]
2607    pub gemini: Option<GeminiConfig>,
2608    /// GitHub Copilot provider configuration
2609    #[serde(skip_serializing_if = "Option::is_none")]
2610    pub copilot: Option<CopilotConfig>,
2611    /// Bodhi proxy provider configuration
2612    #[serde(skip_serializing_if = "Option::is_none")]
2613    pub bodhi: Option<BodhiConfig>,
2614
2615    /// Preserve unknown provider keys (forward compatibility).
2616    #[serde(default, flatten)]
2617    pub extra: BTreeMap<String, Value>,
2618}
2619
2620impl ProviderConfigs {
2621    /// Remove only the known legacy selector and built-in aliases while
2622    /// preserving unknown forward-compatible provider entries in `extra`.
2623    pub fn clear_legacy_builtin_aliases(&mut self) {
2624        self.openai = None;
2625        self.anthropic = None;
2626        self.gemini = None;
2627        self.copilot = None;
2628        self.bodhi = None;
2629        self.extra.remove("provider");
2630    }
2631}
2632
2633/// Feature flags for incremental rollout of new subsystems.
2634#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2635pub struct FeatureFlags {
2636    /// File-authoritative Tickets for a newly bootstrapped Supervisor only.
2637    #[serde(default)]
2638    pub ticket_mutation: bool,
2639    /// Actual fresh one-shot dispatch; independent of Ticket record writes.
2640    #[serde(default)]
2641    pub ticket_dispatch: bool,
2642    /// Enable the ProviderModelRef system (multi-provider + unified model selection).
2643    #[serde(default)]
2644    pub provider_model_ref: bool,
2645    /// Enable MiniLoop-based complexity evaluation and dynamic per-round model switching.
2646    #[serde(default)]
2647    pub dynamic_model_routing: bool,
2648    /// Check a final answer once against this request's recorded tool evidence.
2649    #[serde(default)]
2650    pub final_evidence_check: bool,
2651}
2652
2653/// Default model assignments for specific capabilities.
2654///
2655/// Used when `features.provider_model_ref` is enabled.
2656#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2657pub struct DefaultsConfig {
2658    pub chat: bamboo_domain::ProviderModelRef,
2659    #[serde(default, skip_serializing_if = "Option::is_none")]
2660    pub fast: Option<bamboo_domain::ProviderModelRef>,
2661    #[serde(default, skip_serializing_if = "Option::is_none")]
2662    pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2663    #[serde(default, skip_serializing_if = "Option::is_none")]
2664    pub vision: Option<bamboo_domain::ProviderModelRef>,
2665    #[serde(default, skip_serializing_if = "Option::is_none")]
2666    pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2667    /// Model for planning/coordination tasks (task decomposition, architecture).
2668    /// Falls back to `fast`, then `chat`, when unset.
2669    #[serde(default, skip_serializing_if = "Option::is_none")]
2670    pub planning: Option<bamboo_domain::ProviderModelRef>,
2671    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
2672    /// Falls back to `fast`, then `chat`, when unset.
2673    #[serde(default, skip_serializing_if = "Option::is_none")]
2674    pub search: Option<bamboo_domain::ProviderModelRef>,
2675    /// Model for code review tasks.
2676    /// Falls back to `fast`, then `chat`, when unset.
2677    #[serde(default, skip_serializing_if = "Option::is_none")]
2678    pub code_review: Option<bamboo_domain::ProviderModelRef>,
2679    /// Default model for child SubAgent runs.
2680    /// Falls back to `fast`, then `chat` when unset.
2681    #[serde(
2682        default,
2683        skip_serializing_if = "Option::is_none",
2684        alias = "sub_session"
2685    )]
2686    pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2687    /// Per-subagent-type model overrides.
2688    /// Key = subagent_type (e.g. "researcher", "coder"), Value = ProviderModelRef.
2689    /// Falls back to `sub_agent`, then `fast`, then `chat` when no match is found.
2690    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2691    pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2692}
2693
2694/// Request hook configuration.
2695#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2696pub struct HooksConfig {
2697    /// Image fallback behavior for OpenAI-compatible requests (chat/responses).
2698    #[serde(default)]
2699    pub image_fallback: ImageFallbackHookConfig,
2700}
2701
2702/// Default deadline for one lifecycle command hook.
2703pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2704/// Smallest accepted lifecycle hook deadline at the config API boundary.
2705pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2706/// Largest accepted lifecycle hook deadline (10 minutes). Lifecycle hooks run
2707/// inline with agent progress, so they share the same upper bound as Bamboo's
2708/// interactive shell tool instead of allowing an accidental hours-long stall.
2709pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2710
2711/// Stable user-facing event keys accepted by `lifecycle_hooks`.
2712pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 9] = [
2713    "SessionStart",
2714    "UserPromptSubmit",
2715    "PreToolUse",
2716    "PostToolUse",
2717    "Stop",
2718    "SessionEnd",
2719    "PreCompact",
2720    "Notification",
2721    "BambooModelCallUsage",
2722];
2723
2724fn default_lifecycle_hook_timeout_ms() -> u64 {
2725    DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2726}
2727
2728fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2729    *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2730}
2731
2732fn lifecycle_hook_enabled_default() -> bool {
2733    true
2734}
2735
2736fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2737    *value
2738}
2739
2740/// Config-driven agent lifecycle hooks.
2741///
2742/// Event names deliberately preserve the user-facing PascalCase protocol.
2743/// Server-owned events use the same stable schema as engine-owned events.
2744#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2745pub struct LifecycleHooksConfig {
2746    #[serde(default)]
2747    pub enabled: bool,
2748    #[serde(
2749        default,
2750        rename = "SessionStart",
2751        skip_serializing_if = "Vec::is_empty"
2752    )]
2753    pub session_start: Vec<LifecycleHookGroup>,
2754    #[serde(
2755        default,
2756        rename = "UserPromptSubmit",
2757        skip_serializing_if = "Vec::is_empty"
2758    )]
2759    pub user_prompt_submit: Vec<LifecycleHookGroup>,
2760    #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2761    pub pre_tool_use: Vec<LifecycleHookGroup>,
2762    #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2763    pub post_tool_use: Vec<LifecycleHookGroup>,
2764    #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2765    pub stop: Vec<LifecycleHookGroup>,
2766    #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2767    pub session_end: Vec<LifecycleHookGroup>,
2768    #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2769    pub pre_compact: Vec<LifecycleHookGroup>,
2770    #[serde(
2771        default,
2772        rename = "Notification",
2773        skip_serializing_if = "Vec::is_empty"
2774    )]
2775    pub notification: Vec<LifecycleHookGroup>,
2776    #[serde(
2777        default,
2778        rename = "BambooModelCallUsage",
2779        skip_serializing_if = "Vec::is_empty"
2780    )]
2781    pub model_call_usage: Vec<LifecycleHookGroup>,
2782}
2783
2784impl LifecycleHooksConfig {
2785    pub fn is_empty(&self) -> bool {
2786        !self.enabled
2787            && self.session_start.is_empty()
2788            && self.user_prompt_submit.is_empty()
2789            && self.pre_tool_use.is_empty()
2790            && self.post_tool_use.is_empty()
2791            && self.stop.is_empty()
2792            && self.session_end.is_empty()
2793            && self.pre_compact.is_empty()
2794            && self.notification.is_empty()
2795            && self.model_call_usage.is_empty()
2796    }
2797}
2798
2799/// A matcher and its ordered handler list for one lifecycle event.
2800#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2801pub struct LifecycleHookGroup {
2802    /// A disabled group remains persisted and editable but is not registered
2803    /// for execution. Missing values default to true for old config files.
2804    #[serde(
2805        default = "lifecycle_hook_enabled_default",
2806        skip_serializing_if = "lifecycle_hook_enabled_is_default"
2807    )]
2808    pub enabled: bool,
2809    #[serde(default, skip_serializing_if = "Option::is_none")]
2810    pub matcher: Option<String>,
2811    #[serde(default)]
2812    pub hooks: Vec<LifecycleHookHandler>,
2813}
2814
2815impl Default for LifecycleHookGroup {
2816    fn default() -> Self {
2817        Self {
2818            enabled: true,
2819            matcher: None,
2820            hooks: Vec::new(),
2821        }
2822    }
2823}
2824
2825/// One configured lifecycle hook handler.
2826///
2827/// The internally tagged representation preserves the existing command JSON
2828/// while allowing handler-specific validation for external scripts.
2829#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2830#[serde(tag = "type", rename_all = "lowercase")]
2831pub enum LifecycleHookHandler {
2832    Command {
2833        command: String,
2834        #[serde(
2835            default = "default_lifecycle_hook_timeout_ms",
2836            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2837        )]
2838        timeout_ms: u64,
2839    },
2840    Script {
2841        path: String,
2842        #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2843        runner: LifecycleScriptRunner,
2844        #[serde(
2845            default = "default_lifecycle_hook_timeout_ms",
2846            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2847        )]
2848        timeout_ms: u64,
2849    },
2850}
2851
2852/// Runtime used to execute a lifecycle script.
2853///
2854/// `auto` infers the language from the file extension and tries the system
2855/// runtimes in a deterministic order. Explicit runners are useful when both
2856/// Node.js and Bun are installed or when a deployment standardizes one binary.
2857#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2858#[serde(rename_all = "lowercase")]
2859pub enum LifecycleScriptRunner {
2860    #[default]
2861    Auto,
2862    Node,
2863    Bun,
2864    Python,
2865    Bash,
2866    PowerShell,
2867    Cmd,
2868}
2869
2870impl LifecycleScriptRunner {
2871    pub fn is_auto(&self) -> bool {
2872        matches!(self, Self::Auto)
2873    }
2874
2875    pub fn as_str(self) -> &'static str {
2876        match self {
2877            Self::Auto => "auto",
2878            Self::Node => "node",
2879            Self::Bun => "bun",
2880            Self::Python => "python",
2881            Self::Bash => "bash",
2882            Self::PowerShell => "powershell",
2883            Self::Cmd => "cmd",
2884        }
2885    }
2886
2887    /// Whether this runner can execute the supplied supported script path.
2888    pub fn supports_path(self, path: &str) -> bool {
2889        let extension = lifecycle_script_extension(path);
2890        match self {
2891            Self::Auto => extension.is_some(),
2892            Self::Node | Self::Bun => {
2893                matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2894            }
2895            Self::Python => matches!(extension.as_deref(), Some("py")),
2896            Self::Bash => matches!(extension.as_deref(), Some("sh")),
2897            Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2898            Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2899        }
2900    }
2901}
2902
2903/// Return the normalized extension when the path names a supported lifecycle
2904/// script.
2905pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2906    let extension = std::path::Path::new(path)
2907        .extension()?
2908        .to_str()?
2909        .to_ascii_lowercase();
2910    matches!(
2911        extension.as_str(),
2912        "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2913    )
2914    .then_some(extension)
2915}
2916
2917impl LifecycleHookHandler {
2918    pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2919        Self::Command {
2920            command: command.into(),
2921            timeout_ms,
2922        }
2923    }
2924
2925    pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2926        Self::Script {
2927            path: path.into(),
2928            runner,
2929            timeout_ms,
2930        }
2931    }
2932
2933    pub fn timeout_ms(&self) -> u64 {
2934        match self {
2935            Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2936        }
2937    }
2938}
2939
2940/// Request override configuration for provider-specific HTTP behavior.
2941///
2942/// Overrides are merged in this order (later wins):
2943/// 1. `common`
2944/// 2. `endpoints[endpoint]`
2945/// 3. matching `rules` (sorted by specificity)
2946#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2947pub struct RequestOverridesConfig {
2948    /// Overrides applied to all endpoints.
2949    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2950    pub common: RequestScopeOverride,
2951    /// Endpoint-specific overrides (`chat_completions`, `responses`, `messages`, etc.).
2952    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2953    pub endpoints: BTreeMap<String, RequestScopeOverride>,
2954    /// Model-conditional overrides.
2955    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2956    pub rules: Vec<ModelRequestRule>,
2957}
2958
2959/// A conditional override rule matching a model pattern.
2960#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2961pub struct ModelRequestRule {
2962    /// Model pattern (exact: `gpt-4o`, prefix wildcard: `gpt-5*`).
2963    pub model_pattern: String,
2964    /// Optional endpoint constraint.
2965    #[serde(default, skip_serializing_if = "Option::is_none")]
2966    pub endpoint: Option<String>,
2967    /// Overrides applied when this rule matches.
2968    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2969    pub scope: RequestScopeOverride,
2970}
2971
2972/// Request overrides applied in a specific scope.
2973#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2974pub struct RequestScopeOverride {
2975    /// Extra or overridden HTTP headers.
2976    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2977    pub headers: BTreeMap<String, TemplateExpr>,
2978    /// JSON body patch operations.
2979    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2980    pub body_patch: Vec<BodyPatch>,
2981}
2982
2983impl RequestScopeOverride {
2984    pub fn is_empty(&self) -> bool {
2985        self.headers.is_empty() && self.body_patch.is_empty()
2986    }
2987}
2988
2989/// Body patch operation.
2990#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2991pub struct BodyPatch {
2992    /// Target path (`foo.bar.0` or `/foo/bar/0`).
2993    pub path: String,
2994    /// Operation type.
2995    #[serde(default)]
2996    pub op: BodyPatchOp,
2997    /// Value for `set` operation.
2998    #[serde(default, skip_serializing_if = "Option::is_none")]
2999    pub value: Option<PatchValue>,
3000}
3001
3002/// Supported body patch operations.
3003#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
3004#[serde(rename_all = "snake_case")]
3005pub enum BodyPatchOp {
3006    #[default]
3007    Set,
3008    Remove,
3009}
3010
3011/// Body patch value: either a template expression or a raw JSON value.
3012#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3013#[serde(untagged)]
3014pub enum PatchValue {
3015    Template(TemplateExpr),
3016    Json(Value),
3017}
3018
3019/// String template expression used by headers/body patch values.
3020#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3021#[serde(untagged)]
3022pub enum TemplateExpr {
3023    /// Shorthand literal value.
3024    Literal(String),
3025    /// Structured template expression.
3026    Structured(TemplateExprSpec),
3027}
3028
3029/// Structured template expression.
3030#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3031#[serde(tag = "type", rename_all = "snake_case")]
3032pub enum TemplateExprSpec {
3033    /// Literal string value.
3034    Literal { value: String },
3035    /// Reference a value from Bamboo env vars.
3036    EnvRef {
3037        name: String,
3038        #[serde(default, skip_serializing_if = "Option::is_none")]
3039        fallback: Option<String>,
3040    },
3041    /// Generate a runtime value.
3042    Generated { generator: GeneratedValue },
3043    /// Format string with placeholders (`{env:NAME}`, `{uuid}`, `{unix_ms}`).
3044    Format { template: String },
3045}
3046
3047/// Supported generated value kinds.
3048#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3049#[serde(rename_all = "snake_case")]
3050pub enum GeneratedValue {
3051    Uuid,
3052    UnixMs,
3053}
3054
3055/// Global tool toggle configuration.
3056#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3057pub struct ToolsConfig {
3058    /// Tool names that are disabled globally.
3059    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3060    pub disabled: Vec<String>,
3061
3062    /// Preserve tool configuration owned by newer Bamboo versions or plugins.
3063    #[serde(default, flatten)]
3064    pub extra: BTreeMap<String, Value>,
3065}
3066
3067impl ToolsConfig {
3068    fn is_empty(&self) -> bool {
3069        self.disabled.is_empty() && self.extra.is_empty()
3070    }
3071}
3072
3073/// Global skill toggle configuration.
3074#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3075pub struct SkillsConfig {
3076    /// Skill IDs that are disabled globally.
3077    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3078    pub disabled: Vec<String>,
3079
3080    /// Preserve skill configuration owned by newer Bamboo versions or plugins.
3081    #[serde(default, flatten)]
3082    pub extra: BTreeMap<String, Value>,
3083}
3084
3085impl SkillsConfig {
3086    fn is_empty(&self) -> bool {
3087        self.disabled.is_empty() && self.extra.is_empty()
3088    }
3089}
3090
3091/// When a request contains image parts but the effective provider path is text-only,
3092/// we can either:
3093/// - error fast (preferred for strict setups), or
3094/// - degrade gracefully by replacing images with a placeholder text.
3095#[derive(Debug, Clone, Serialize, Deserialize)]
3096pub struct ImageFallbackHookConfig {
3097    #[serde(default = "default_true_hooks")]
3098    pub enabled: bool,
3099
3100    /// "placeholder" (default) or "error"
3101    #[serde(default = "default_image_fallback_mode")]
3102    pub mode: String,
3103}
3104
3105impl Default for ImageFallbackHookConfig {
3106    fn default() -> Self {
3107        Self {
3108            enabled: default_true_hooks(),
3109            mode: default_image_fallback_mode(),
3110        }
3111    }
3112}
3113
3114fn default_image_fallback_mode() -> String {
3115    "placeholder".to_string()
3116}
3117
3118fn default_true_hooks() -> bool {
3119    // Default to disabled so image inputs are preserved unless the user explicitly
3120    // opts into fallback rewriting (placeholder/error/ocr).
3121    false
3122}
3123
3124/// OpenAI provider configuration
3125///
3126/// # Example
3127///
3128/// ```json
3129/// "openai": {
3130///   "api_key": "sk-...",
3131///   "base_url": "https://api.openai.com/v1",
3132///   "model": "gpt-4"
3133/// }
3134/// ```
3135pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
3136
3137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3138pub struct OpenAIConfig {
3139    /// OpenAI API key (plaintext, in-memory only).
3140    ///
3141    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3142    #[serde(default, skip_serializing)]
3143    pub api_key: String,
3144    /// Encrypted OpenAI API key (nonce:ciphertext).
3145    #[serde(default, skip_serializing_if = "Option::is_none")]
3146    pub api_key_encrypted: Option<String>,
3147    /// Stable reference to the isolated credential store.
3148    #[serde(default, skip_serializing_if = "Option::is_none")]
3149    pub credential_ref: Option<crate::CredentialRef>,
3150    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3151    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3152    /// `api_key_encrypted` on save (that would bake the secret into
3153    /// config.json). Not (de)serialized. (#253)
3154    #[serde(skip)]
3155    pub api_key_from_env: bool,
3156    /// Custom API base URL (for Azure or self-hosted deployments)
3157    #[serde(skip_serializing_if = "Option::is_none")]
3158    pub base_url: Option<String>,
3159    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
3160    #[serde(skip_serializing_if = "Option::is_none")]
3161    pub model: Option<String>,
3162    /// Fast/cheap model for lightweight tasks (title generation and summarization).
3163    /// Falls back to `model` when not set.
3164    #[serde(default, skip_serializing_if = "Option::is_none")]
3165    pub fast_model: Option<String>,
3166    /// Vision-capable model for image understanding tasks.
3167    /// Falls back to `model` when not set.
3168    #[serde(default, skip_serializing_if = "Option::is_none")]
3169    pub vision_model: Option<String>,
3170    /// Default reasoning effort for OpenAI requests.
3171    #[serde(skip_serializing_if = "Option::is_none")]
3172    pub reasoning_effort: Option<ReasoningEffort>,
3173
3174    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3175    ///
3176    /// Example:
3177    /// ```json
3178    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3179    /// ```
3180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3181    pub responses_only_models: Vec<String>,
3182    /// Optional request overrides (headers/body patches/model rules).
3183    #[serde(default, skip_serializing_if = "Option::is_none")]
3184    pub request_overrides: Option<RequestOverridesConfig>,
3185
3186    /// Preserve unknown keys under `providers.openai`.
3187    #[serde(default, flatten)]
3188    pub extra: BTreeMap<String, Value>,
3189}
3190
3191impl OpenAIConfig {
3192    /// Whether Bamboo may lower its provider-neutral cache plan into GPT-5.6+
3193    /// `prompt_cache_options` and `prompt_cache_breakpoint` fields.
3194    ///
3195    /// This defaults to enabled. OpenAI-compatible upstreams that have not yet
3196    /// implemented the explicit-cache request fields can opt out without
3197    /// disabling `prompt_cache_key` or the upstream's implicit prompt cache.
3198    pub fn explicit_prompt_cache_enabled(&self) -> bool {
3199        self.extra
3200            .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3201            .and_then(Value::as_bool)
3202            .unwrap_or(true)
3203    }
3204}
3205
3206/// Anthropic provider configuration
3207///
3208/// # Example
3209///
3210/// ```json
3211/// "anthropic": {
3212///   "api_key": "sk-ant-...",
3213///   "model": "claude-3-5-sonnet-20241022",
3214///   "max_tokens": 4096
3215/// }
3216/// ```
3217#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3218pub struct AnthropicConfig {
3219    /// Anthropic API key (plaintext, in-memory only).
3220    ///
3221    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3222    #[serde(default, skip_serializing)]
3223    pub api_key: String,
3224    /// Encrypted Anthropic API key (nonce:ciphertext).
3225    #[serde(default, skip_serializing_if = "Option::is_none")]
3226    pub api_key_encrypted: Option<String>,
3227    /// Stable reference to the isolated credential store.
3228    #[serde(default, skip_serializing_if = "Option::is_none")]
3229    pub credential_ref: Option<crate::CredentialRef>,
3230    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3231    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3232    /// `api_key_encrypted` on save (that would bake the secret into
3233    /// config.json). Not (de)serialized. (#253)
3234    #[serde(skip)]
3235    pub api_key_from_env: bool,
3236    /// Custom API base URL
3237    #[serde(skip_serializing_if = "Option::is_none")]
3238    pub base_url: Option<String>,
3239    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
3240    #[serde(skip_serializing_if = "Option::is_none")]
3241    pub model: Option<String>,
3242    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3243    /// Falls back to `model` when not set.
3244    #[serde(default, skip_serializing_if = "Option::is_none")]
3245    pub fast_model: Option<String>,
3246    /// Vision-capable model for image understanding tasks.
3247    /// Falls back to `model` when not set.
3248    #[serde(default, skip_serializing_if = "Option::is_none")]
3249    pub vision_model: Option<String>,
3250    /// Maximum tokens in model response
3251    #[serde(skip_serializing_if = "Option::is_none")]
3252    pub max_tokens: Option<u32>,
3253    /// Default reasoning effort for Anthropic requests.
3254    #[serde(skip_serializing_if = "Option::is_none")]
3255    pub reasoning_effort: Option<ReasoningEffort>,
3256    /// Optional request overrides (headers/body patches/model rules).
3257    #[serde(default, skip_serializing_if = "Option::is_none")]
3258    pub request_overrides: Option<RequestOverridesConfig>,
3259
3260    /// Unconditionally replay a prior turn's `reasoning` as a `thinking`
3261    /// content block, regardless of whether bamboo captured a valid signature
3262    /// for it (issue #520).
3263    ///
3264    /// Defaults to `false`/absent, which is REQUIRED for real Anthropic: it
3265    /// requires `thinking` input blocks to carry a signature it minted itself,
3266    /// and bamboo never captures one, so an unconditionally-replayed block is
3267    /// always rejected with a 400 (either because it's foreign — minted by a
3268    /// different provider after a mid-session model switch — or because it's
3269    /// an unsigned copy of Claude's own prior turn).
3270    ///
3271    /// Set this to `true` only when pointing `base_url` at an
3272    /// Anthropic-COMPATIBLE upstream (e.g. GLM's `/anthropic` endpoint) that
3273    /// has the opposite contract: it requires the `thinking` block to be
3274    /// present whenever thinking is enabled, but never validates its
3275    /// signature.
3276    #[serde(default, skip_serializing_if = "Option::is_none")]
3277    pub thinking_replay_always: Option<bool>,
3278
3279    /// Preserve unknown keys under `providers.anthropic`.
3280    #[serde(default, flatten)]
3281    pub extra: BTreeMap<String, Value>,
3282}
3283
3284/// Google Gemini provider configuration
3285///
3286/// # Example
3287///
3288/// ```json
3289/// "gemini": {
3290///   "api_key": "AIza...",
3291///   "model": "gemini-2.0-flash-exp"
3292/// }
3293/// ```
3294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3295pub struct GeminiConfig {
3296    /// Google AI API key (plaintext, in-memory only).
3297    ///
3298    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3299    #[serde(default, skip_serializing)]
3300    pub api_key: String,
3301    /// Encrypted Google AI API key (nonce:ciphertext).
3302    #[serde(default, skip_serializing_if = "Option::is_none")]
3303    pub api_key_encrypted: Option<String>,
3304    /// Stable reference to the isolated credential store.
3305    #[serde(default, skip_serializing_if = "Option::is_none")]
3306    pub credential_ref: Option<crate::CredentialRef>,
3307    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3308    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3309    /// `api_key_encrypted` on save (that would bake the secret into
3310    /// config.json). Not (de)serialized. (#253)
3311    #[serde(skip)]
3312    pub api_key_from_env: bool,
3313    /// Custom API base URL
3314    #[serde(skip_serializing_if = "Option::is_none")]
3315    pub base_url: Option<String>,
3316    /// Default model to use (e.g., "gemini-2.0-flash-exp")
3317    #[serde(skip_serializing_if = "Option::is_none")]
3318    pub model: Option<String>,
3319    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3320    /// Falls back to `model` when not set.
3321    #[serde(default, skip_serializing_if = "Option::is_none")]
3322    pub fast_model: Option<String>,
3323    /// Vision-capable model for image understanding tasks.
3324    /// Falls back to `model` when not set.
3325    #[serde(default, skip_serializing_if = "Option::is_none")]
3326    pub vision_model: Option<String>,
3327    /// Default reasoning effort for Gemini requests.
3328    #[serde(skip_serializing_if = "Option::is_none")]
3329    pub reasoning_effort: Option<ReasoningEffort>,
3330    /// Optional request overrides (headers/body patches/model rules).
3331    #[serde(default, skip_serializing_if = "Option::is_none")]
3332    pub request_overrides: Option<RequestOverridesConfig>,
3333
3334    /// Preserve unknown keys under `providers.gemini`.
3335    #[serde(default, flatten)]
3336    pub extra: BTreeMap<String, Value>,
3337}
3338
3339/// GitHub Copilot provider configuration
3340///
3341/// # Example
3342///
3343/// ```json
3344/// "copilot": {
3345///   "enabled": true,
3346///   "headless_auth": false,
3347///   "model": "gpt-4o"
3348/// }
3349/// ```
3350#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3351pub struct CopilotConfig {
3352    /// Whether Copilot provider is enabled
3353    #[serde(default)]
3354    pub enabled: bool,
3355    /// Print login URL to console instead of opening browser
3356    #[serde(default)]
3357    pub headless_auth: bool,
3358    /// Default model to use for Copilot (used when clients request the "default" model)
3359    #[serde(skip_serializing_if = "Option::is_none")]
3360    pub model: Option<String>,
3361    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3362    /// Falls back to `model` when not set.
3363    #[serde(default, skip_serializing_if = "Option::is_none")]
3364    pub fast_model: Option<String>,
3365    /// Vision-capable model for image understanding tasks.
3366    /// Falls back to `model` when not set.
3367    #[serde(default, skip_serializing_if = "Option::is_none")]
3368    pub vision_model: Option<String>,
3369    /// Default reasoning effort for Copilot requests.
3370    #[serde(skip_serializing_if = "Option::is_none")]
3371    pub reasoning_effort: Option<ReasoningEffort>,
3372
3373    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3374    ///
3375    /// This is useful for newer Copilot models that only support Responses-style requests.
3376    ///
3377    /// Example:
3378    /// ```json
3379    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3380    /// ```
3381    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3382    pub responses_only_models: Vec<String>,
3383    /// Optional request overrides (headers/body patches/model rules).
3384    #[serde(default, skip_serializing_if = "Option::is_none")]
3385    pub request_overrides: Option<RequestOverridesConfig>,
3386
3387    /// Preserve unknown keys under `providers.copilot`.
3388    #[serde(default, flatten)]
3389    pub extra: BTreeMap<String, Value>,
3390}
3391
3392/// Bodhi proxy provider configuration.
3393///
3394/// Routes LLM requests through a bodhi-server instance so that raw provider
3395/// API keys never reach the client.
3396#[derive(Debug, Clone, Serialize, Deserialize)]
3397pub struct BodhiConfig {
3398    /// Bodhi server API key (e.g. "bhi_sk_xxx").  In-memory only.
3399    #[serde(default, skip_serializing)]
3400    pub api_key: String,
3401    /// Encrypted form of the API key stored on disk.
3402    #[serde(default, skip_serializing_if = "Option::is_none")]
3403    pub api_key_encrypted: Option<String>,
3404    /// Stable reference to the isolated credential store.
3405    #[serde(default, skip_serializing_if = "Option::is_none")]
3406    pub credential_ref: Option<crate::CredentialRef>,
3407    /// Bodhi server base URL.
3408    #[serde(skip_serializing_if = "Option::is_none")]
3409    pub base_url: Option<String>,
3410    /// Which upstream provider to route through bodhi ("openai", "anthropic", "gemini").
3411    #[serde(skip_serializing_if = "Option::is_none")]
3412    pub target_provider: Option<String>,
3413    /// Default reasoning effort.
3414    #[serde(skip_serializing_if = "Option::is_none")]
3415    pub reasoning_effort: Option<ReasoningEffort>,
3416
3417    /// Preserve unknown keys.
3418    #[serde(default, flatten)]
3419    pub extra: BTreeMap<String, Value>,
3420}
3421
3422/// Returns the default provider name ("anthropic")
3423fn default_provider() -> String {
3424    "anthropic".to_string()
3425}
3426
3427// ─── Provider Instance Configuration ──────────────────────────────────
3428
3429/// Configuration for a single provider instance.
3430///
3431/// Multiple instances of the same provider type (e.g. two OpenAI accounts)
3432/// can coexist. Each instance is identified by a stable `instance_id` that
3433/// is used as the routing key in [`ProviderModelRef::provider`] and the
3434/// provider registry.
3435///
3436/// # Example (config.json)
3437///
3438/// ```json
3439/// {
3440///   "provider_instances": {
3441///     "openai-work": {
3442///       "provider_type": "openai",
3443///       "label": "OpenAI (Work)",
3444///       "api_key": "sk-...",
3445///       "model": "gpt-4o"
3446///     },
3447///     "openai-personal": {
3448///       "provider_type": "openai",
3449///       "label": "OpenAI (Personal)",
3450///       "api_key": "sk-...",
3451///       "base_url": "https://api.openai.com/v1",
3452///       "model": "gpt-4o-mini"
3453///     }
3454///   },
3455///   "default_provider_instance": "openai-work"
3456/// }
3457/// ```
3458#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3459pub struct ProviderInstanceConfig {
3460    /// Which provider backend this instance targets.
3461    ///
3462    /// Must be one of [`AVAILABLE_PROVIDERS`]: `"openai"`, `"anthropic"`,
3463    /// `"gemini"`, `"copilot"`, `"bodhi"`.
3464    pub provider_type: String,
3465
3466    /// Human-readable label shown in the UI / catalog.
3467    #[serde(default, skip_serializing_if = "Option::is_none")]
3468    pub label: Option<String>,
3469
3470    /// API key (plaintext in memory, encrypted at rest via `api_key_encrypted`).
3471    #[serde(default, skip_serializing)]
3472    pub api_key: String,
3473
3474    /// Encrypted API key (nonce:ciphertext). Written to disk; decrypted into
3475    /// `api_key` on load.
3476    #[serde(default, skip_serializing_if = "Option::is_none")]
3477    pub api_key_encrypted: Option<String>,
3478
3479    /// Stable reference to the isolated credential store.
3480    #[serde(default, skip_serializing_if = "Option::is_none")]
3481    pub credential_ref: Option<crate::CredentialRef>,
3482
3483    /// Custom base URL override.
3484    #[serde(default, skip_serializing_if = "Option::is_none")]
3485    pub base_url: Option<String>,
3486
3487    /// Default chat model for this instance.
3488    #[serde(default, skip_serializing_if = "Option::is_none")]
3489    pub model: Option<String>,
3490
3491    /// Fast/cheap model for lightweight tasks.
3492    #[serde(default, skip_serializing_if = "Option::is_none")]
3493    pub fast_model: Option<String>,
3494
3495    /// Vision-capable model.
3496    #[serde(default, skip_serializing_if = "Option::is_none")]
3497    pub vision_model: Option<String>,
3498
3499    /// Default reasoning effort.
3500    #[serde(default, skip_serializing_if = "Option::is_none")]
3501    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3502
3503    /// Models that must use the Responses API upstream (OpenAI only).
3504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3505    pub responses_only_models: Vec<String>,
3506
3507    /// Optional request overrides (headers/body patches/model rules).
3508    #[serde(default, skip_serializing_if = "Option::is_none")]
3509    pub request_overrides: Option<RequestOverridesConfig>,
3510
3511    /// Whether this instance is enabled. Disabled instances are skipped
3512    /// during registry construction.
3513    #[serde(default = "default_true")]
3514    pub enabled: bool,
3515
3516    /// Provider-type-specific extra fields preserved through (de)serialization.
3517    #[serde(default, flatten)]
3518    pub extra: BTreeMap<String, Value>,
3519}
3520
3521fn default_true() -> bool {
3522    true
3523}
3524
3525/// Returns the default server port (9562)
3526fn default_port() -> u16 {
3527    9562
3528}
3529
3530/// Returns the default bind address (127.0.0.1)
3531fn default_bind() -> String {
3532    "127.0.0.1".to_string()
3533}
3534
3535/// Returns the default worker count (10)
3536fn default_workers() -> usize {
3537    10
3538}
3539
3540/// Returns the default data directory (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3541fn default_data_dir() -> PathBuf {
3542    super::paths::bamboo_dir()
3543}
3544
3545/// HTTP server configuration
3546#[derive(Debug, Clone, Serialize, Deserialize)]
3547pub struct ServerConfig {
3548    /// Port to listen on
3549    #[serde(default = "default_port")]
3550    pub port: u16,
3551
3552    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
3553    #[serde(default = "default_bind")]
3554    pub bind: String,
3555
3556    /// Static files directory (for Docker mode)
3557    pub static_dir: Option<PathBuf>,
3558
3559    /// Worker count for Actix-web
3560    #[serde(default = "default_workers")]
3561    pub workers: usize,
3562
3563    /// v2 (API v2 transport, #181): optional TLS termination config. When both
3564    /// `cert_file` and `key_file` are given, bamboo terminates TLS itself
3565    /// (rustls, no reverse proxy) and serves `https://` — intended for the
3566    /// public `0.0.0.0` face. When absent, the server keeps the plain `.bind()`
3567    /// / `.listen()` path unchanged (desktop loopback stays plaintext). Missing
3568    /// or unparseable cert/key files are fail-fast at startup, never a silent
3569    /// downgrade to plaintext.
3570    #[serde(default, skip_serializing_if = "Option::is_none")]
3571    pub tls: Option<TlsConfig>,
3572
3573    /// Preserve unknown keys under `server`.
3574    #[serde(default, flatten)]
3575    pub extra: BTreeMap<String, Value>,
3576}
3577
3578impl Default for ServerConfig {
3579    fn default() -> Self {
3580        Self {
3581            port: default_port(),
3582            bind: default_bind(),
3583            static_dir: None,
3584            workers: default_workers(),
3585            tls: None,
3586            extra: BTreeMap::new(),
3587        }
3588    }
3589}
3590
3591/// Manual TLS certificate configuration (current stage; ACME deferred).
3592///
3593/// Both fields point at PEM files: `cert_file` is the full certificate chain
3594/// (leaf → intermediates → root), `key_file` is the matching private key
3595/// (PKCS#8 or RSA). See `docs/api-v2-transport.md` §3.
3596#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3597pub struct TlsConfig {
3598    /// PEM certificate chain (leaf → intermediates → root).
3599    pub cert_file: PathBuf,
3600    /// PEM private key (PKCS#8 or RSA).
3601    pub key_file: PathBuf,
3602}
3603
3604/// Proxy authentication credentials
3605#[derive(Debug, Clone, Serialize, Deserialize)]
3606pub struct ProxyAuth {
3607    /// Proxy username
3608    pub username: String,
3609    /// Proxy password
3610    pub password: String,
3611}
3612
3613/// Parse a boolean value from environment variable strings
3614///
3615/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
3616fn parse_bool_env(value: &str) -> bool {
3617    matches!(
3618        value.trim().to_ascii_lowercase().as_str(),
3619        "1" | "true" | "yes" | "y" | "on"
3620    )
3621}
3622
3623fn expand_user_path(value: &str) -> PathBuf {
3624    let trimmed = value.trim();
3625    if let Some(rest) = trimmed.strip_prefix("~/") {
3626        if let Some(home) = dirs::home_dir() {
3627            return home.join(rest);
3628        }
3629    }
3630    PathBuf::from(trimmed)
3631}
3632
3633impl Default for Config {
3634    fn default() -> Self {
3635        // In-memory defaults ONLY. `default()` must not touch the filesystem or
3636        // environment: it was delegating to `new()` → `from_data_dir(None)`,
3637        // which read config.json from disk, applied BAMBOO_* env overrides, and
3638        // published to the global env-var cache. That made every `..Default::
3639        // default()` struct-update and every test silently disk-dependent and
3640        // let non-server callers clobber the server's in-memory config cache.
3641        // `create_default()` is the pure in-memory constructor; disk loading is
3642        // the explicit job of `new()` / `from_data_dir()`. #38.
3643        Self::create_default()
3644    }
3645}
3646
3647/// Prompt-safe snapshot of configured env vars.
3648#[derive(Debug, Clone, PartialEq, Eq)]
3649pub struct PromptSafeEnvVarEntry {
3650    pub name: String,
3651    pub secret: bool,
3652    pub description: Option<String>,
3653}
3654
3655/// Global cache of user-managed env vars for injection into child processes.
3656///
3657/// Updated whenever the config is loaded or reloaded via [`Config::publish_env_vars`].
3658static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3659
3660static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3661
3662fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3663    ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3664}
3665
3666fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3667    PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3668}
3669
3670impl Config {
3671    fn from_parts(
3672        values: ConfigValues,
3673        memory: Option<MemoryConfig>,
3674        subagents: SubagentsConfig,
3675        providers: ProviderConfigs,
3676    ) -> Self {
3677        Self {
3678            values,
3679            memory: crate::MemoryConfigModule(memory),
3680            subagents: crate::SubagentsConfigModule(subagents),
3681            providers: crate::ProviderConfigsModule(providers),
3682            recovery_status: None,
3683        }
3684    }
3685
3686    /// Crate-internal seam used by the modular section facade.
3687    ///
3688    /// Keeping this conversion here lets the facade exhaustively destructure
3689    /// [`ConfigValues`] in both directions without exposing the compatibility
3690    /// facade's private storage to downstream crates.
3691    pub(crate) fn section_values(&self) -> ConfigValues {
3692        self.values.clone()
3693    }
3694
3695    pub(crate) fn from_section_parts(
3696        values: ConfigValues,
3697        memory: Option<MemoryConfig>,
3698        subagents: SubagentsConfig,
3699        providers: ProviderConfigs,
3700    ) -> Self {
3701        Self::from_parts(values, memory, subagents, providers)
3702    }
3703
3704    /// Compatibility accessor for independently persisted memory settings.
3705    pub fn memory(&self) -> &Option<MemoryConfig> {
3706        &self.memory.0
3707    }
3708
3709    pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3710        &mut self.memory.0
3711    }
3712
3713    /// Compatibility accessor for independently persisted sub-agent settings.
3714    pub fn subagents(&self) -> &SubagentsConfig {
3715        &self.subagents.0
3716    }
3717
3718    pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3719        &mut self.subagents.0
3720    }
3721
3722    /// Compatibility accessor for independently persisted legacy providers.
3723    pub fn providers(&self) -> &ProviderConfigs {
3724        &self.providers.0
3725    }
3726
3727    pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3728        &mut self.providers.0
3729    }
3730
3731    /// Canonicalize only the durable provider view when instance routing is
3732    /// authoritative. Unknown provider entries remain available for forward
3733    /// compatibility.
3734    pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3735        if self
3736            .default_provider_instance
3737            .as_ref()
3738            .is_some_and(|id| self.provider_instances.contains_key(id))
3739        {
3740            self.providers.0.clear_legacy_builtin_aliases();
3741        }
3742    }
3743
3744    /// Build the legacy full-config JSON view used by in-memory patching APIs.
3745    ///
3746    /// Public serde and patch/dot-path callers retain the historical full
3747    /// configuration shape. This value is never the persistence representation:
3748    /// [`Config::save_to_dir`] explicitly writes a root-only DTO plus sidecars.
3749    pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3750        let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3751        let object = value
3752            .as_object_mut()
3753            .expect("ConfigRoot always serializes as a JSON object");
3754        object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3755        object.insert(
3756            "subagents".to_string(),
3757            serde_json::to_value(self.subagents())?,
3758        );
3759        object.insert(
3760            "providers".to_string(),
3761            serde_json::to_value(self.providers())?,
3762        );
3763        Ok(value)
3764    }
3765
3766    /// Load configuration from file with environment variable overrides
3767    ///
3768    /// Configuration loading order:
3769    /// 1. Try loading from `config.json` (`{data_dir}/config.json`)
3770    /// 2. Use defaults
3771    /// 3. Apply environment variable overrides (highest priority)
3772    ///
3773    /// # Environment Variables
3774    ///
3775    /// - `BAMBOO_PORT`: Override server port
3776    /// - `BAMBOO_BIND`: Override bind address
3777    /// - `BAMBOO_DATA_DIR`: Override data directory
3778    /// - `BAMBOO_PROVIDER`: Override default provider
3779    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
3780    /// - `BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION`: Override project durable-memory index prompt injection
3781    /// - `BAMBOO_MEMORY_RELEVANT_RECALL`: Override relevant durable-memory recall prompt injection
3782    /// - `BAMBOO_MEMORY_RELEVANT_RECALL_RERANK`: Override model-based relevant recall reranking
3783    /// - `BAMBOO_MEMORY_PROJECT_FIRST_DREAM`: Override project-first Dream prompt behavior
3784    pub fn new() -> Self {
3785        Self::from_data_dir(None)
3786    }
3787
3788    fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3789        let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3790            Ok(facade) => facade.effective_config(),
3791            Err(error) => {
3792                tracing::warn!(
3793                    error = %error,
3794                    "completed modular configuration is unavailable; refusing legacy-root fallback"
3795                );
3796                Self::create_default()
3797            }
3798        };
3799
3800        if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3801            tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3802            config.proxy_auth = None;
3803        }
3804        if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3805            tracing::warn!(error = %error, "provider credential hydration unavailable");
3806        }
3807        if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3808            tracing::warn!(error = %error, "MCP credential hydration unavailable");
3809        }
3810        if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3811            tracing::warn!(error = %error, "env credential hydration unavailable");
3812            for entry in &mut config.env_vars {
3813                if entry.secret {
3814                    entry.value.clear();
3815                }
3816            }
3817        }
3818        if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3819            tracing::warn!(error = %error, "cluster credential hydration unavailable");
3820            config.clear_cluster_runtime_credentials();
3821        }
3822        if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3823            tracing::warn!(error = %error, "notification credential hydration unavailable");
3824            config.notifications.ntfy.token = None;
3825            config.notifications.bark.device_key = None;
3826        }
3827        if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3828            tracing::warn!(error = %error, "connect credential hydration unavailable");
3829            for platform in &mut config.connect.platforms {
3830                platform.token = None;
3831                platform.app_secret = None;
3832            }
3833        }
3834        if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3835            tracing::warn!(error = %error, "access-control credential hydration unavailable");
3836            config.clear_access_control_runtime_verifiers();
3837        }
3838        if let Some(broker) = config.subagents_mut().broker.as_mut() {
3839            if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3840                tracing::warn!(error = %error, "external broker credential hydration unavailable");
3841                broker.token.clear();
3842            }
3843        }
3844        config.normalize_tool_settings();
3845        config.normalize_skill_settings();
3846        config.normalize_plugin_trust_settings();
3847        config.extra.remove("data_dir");
3848        if apply_env {
3849            config.apply_env_overrides();
3850        }
3851        if publish {
3852            config.publish_env_vars();
3853        }
3854        config
3855    }
3856
3857    /// Load configuration from a specific data directory.
3858    ///
3859    /// Use [`Config::from_data_dir`] (publishes env vars to the global cache, for
3860    /// the context that OWNS the cache — the server bootstrap) or
3861    /// [`Config::from_data_dir_without_publish`] (for non-owning readers that must
3862    /// not clobber the live cache). #40.
3863    ///
3864    /// * `data_dir` - Optional data directory path. If None, uses default (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3865    fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3866        // Determine data_dir early (needed to find config file)
3867        let data_dir = data_dir
3868            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3869            .unwrap_or_else(default_data_dir);
3870
3871        match crate::modular_authority_boundary_present(&data_dir) {
3872            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3873            Ok(false) => {}
3874            Err(error) => {
3875                tracing::warn!(
3876                    error = %error,
3877                    "modular configuration marker is unavailable; refusing legacy-root fallback"
3878                );
3879                let mut config = Self::create_default();
3880                if apply_env {
3881                    config.apply_env_overrides();
3882                }
3883                if publish {
3884                    config.publish_env_vars();
3885                }
3886                return config;
3887            }
3888        }
3889
3890        // Finish any shared manifest-committed credential extraction before
3891        // reading even one member of the transaction, then plan only the
3892        // provider/MCP/root domains. The optional broker has its own planner so
3893        // malformed broker metadata cannot suppress main configuration loading.
3894        let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3895            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3896            .map_err(|error| {
3897                tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3898                error
3899            })
3900            .is_ok();
3901        let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3902            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3903            .map_err(|error| {
3904                tracing::warn!(error = %error, "cluster credential migration unavailable");
3905                error
3906            })
3907            .is_ok();
3908
3909        // A recovery performed by either legacy credential planner may have
3910        // completed the modular split. Reclassify immediately before touching
3911        // config.json so a pending split cannot race this compatibility load
3912        // back into legacy authority.
3913        match crate::modular_authority_boundary_present(&data_dir) {
3914            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3915            Ok(false) => {}
3916            Err(error) => {
3917                tracing::warn!(
3918                    error = %error,
3919                    "modular configuration boundary is unavailable; refusing legacy-root fallback"
3920                );
3921                let mut config = Self::create_default();
3922                if apply_env {
3923                    config.apply_env_overrides();
3924                }
3925                if publish {
3926                    config.publish_env_vars();
3927                }
3928                return config;
3929            }
3930        }
3931
3932        let config_path = data_dir.join("config.json");
3933
3934        let mut config = if config_path.exists() {
3935            if let Ok(content) = std::fs::read_to_string(&config_path) {
3936                Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3937                    // Don't silently discard the user's config on corruption.
3938                    // Quarantine the unparseable file, then recover the MOST recent
3939                    // intent in order: (1) SALVAGE the still-valid fields from the
3940                    // corrupt file (a single bad field shouldn't drop everything),
3941                    // (2) the last-known-good config.json.bak, (3) defaults.
3942                    // #37 / #135. Tag the recovered config with a
3943                    // ConfigRecoveryStatus (unconfirmed) so save_to_dir refuses to
3944                    // overwrite config.json until the caller confirms — the
3945                    // quarantined original stays hand-recoverable until then. #153.
3946                    tracing::warn!(
3947                        "Failed to parse config.json ({}); quarantining it and attempting recovery",
3948                        e
3949                    );
3950                    let quarantine_path = quarantine_corrupt_config(&config_path);
3951                    let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3952                        .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3953                        .or_else(|| {
3954                            Self::load_backup(&data_dir).map(|(cfg, generation)| {
3955                                (cfg, ConfigRecoverySource::Backup { generation })
3956                            })
3957                        })
3958                        .unwrap_or_else(|| {
3959                            tracing::warn!(
3960                                "Could not salvage and no usable config.json.bak; using defaults"
3961                            );
3962                            (Self::create_default(), ConfigRecoverySource::Defaults)
3963                        });
3964                    recovered.recovery_status = Some(ConfigRecoveryStatus {
3965                        source,
3966                        quarantine_path,
3967                        confirmed: false,
3968                    });
3969                    recovered
3970                })
3971            } else {
3972                Self::create_default()
3973            }
3974        } else {
3975            Self::create_default()
3976        };
3977
3978        // Phase-1 registrar migration: an existing sidecar is authoritative;
3979        // when absent, retain the legacy inline value loaded from config.json.
3980        // A malformed sidecar is never rewritten during load and the inline
3981        // value remains available, preventing a bad independent edit from
3982        // erasing the user's last usable configuration.
3983        let mut memory_module = config.memory.clone();
3984        match memory_module.load_sync(&data_dir) {
3985            Ok(true) => config.memory = memory_module,
3986            Ok(false) => {}
3987            Err(error) => tracing::warn!(
3988                "Failed to load memory.json; using legacy config.json memory: {error}"
3989            ),
3990        }
3991        let mut subagents_module = config.subagents.clone();
3992        match subagents_module.load_sync(&data_dir) {
3993            Ok(true) => config.subagents = subagents_module,
3994            Ok(false) => {}
3995            Err(error) => tracing::warn!(
3996                "Failed to load subagents.json; using legacy config.json subagents: {error}"
3997            ),
3998        }
3999        if provider_mcp_ready {
4000            let mut providers_module = config.providers.clone();
4001            match providers_module.load_sync(&data_dir) {
4002                Ok(true) => config.providers = providers_module,
4003                Ok(false) => {}
4004                Err(error) => tracing::warn!(
4005                    "Failed to load providers.json; using legacy config.json providers: {error}"
4006                ),
4007            }
4008        }
4009
4010        // Decrypt encrypted proxy auth into in-memory plaintext form.
4011        config.hydrate_proxy_auth_from_encrypted();
4012        if provider_mcp_ready {
4013            if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
4014                tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
4015                config.proxy_auth = None;
4016            }
4017        }
4018        // Decrypt encrypted provider API keys into in-memory plaintext form.
4019        config.hydrate_provider_api_keys_from_encrypted();
4020        // Decrypt encrypted provider-instance API keys into in-memory plaintext form.
4021        config.hydrate_provider_instance_api_keys_from_encrypted();
4022        // Decrypt encrypted MCP secrets into in-memory plaintext form.
4023        config.hydrate_mcp_secrets_from_encrypted();
4024        if provider_mcp_ready {
4025            if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
4026                tracing::warn!(error = %error, "provider credential hydration unavailable");
4027            }
4028            if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
4029                tracing::warn!(error = %error, "MCP credential hydration unavailable");
4030            }
4031        }
4032        // Decrypt encrypted env vars into in-memory plaintext form.
4033        config.hydrate_env_vars_from_encrypted();
4034        if provider_mcp_ready {
4035            if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
4036                tracing::warn!(error = %error, "env credential hydration unavailable");
4037                for entry in &mut config.env_vars {
4038                    if entry.secret {
4039                        entry.value.clear();
4040                    }
4041                }
4042            }
4043        }
4044        // Cluster migration is deliberately independent from provider/MCP
4045        // readiness. A malformed optional fabric fails only cluster runtime
4046        // authentication, while missing/corrupt refs never fall back to legacy
4047        // ciphertext or an unauthenticated SSH attempt.
4048        if cluster_ready {
4049            if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
4050                tracing::warn!(error = %error, "cluster credential hydration unavailable");
4051                config.clear_cluster_runtime_credentials();
4052            }
4053        } else {
4054            config.clear_cluster_runtime_credentials();
4055        }
4056        // Decrypt the encrypted broker token into in-memory plaintext.
4057        config.hydrate_broker_token_from_encrypted();
4058        // Decrypt encrypted notification-channel secrets into in-memory plaintext.
4059        config.hydrate_notifications_from_encrypted();
4060        if provider_mcp_ready {
4061            if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
4062                tracing::warn!(error = %error, "notification credential hydration unavailable");
4063                config.notifications.ntfy.token = None;
4064                config.notifications.bark.device_key = None;
4065            }
4066        } else {
4067            // A pending or unreadable credential migration means the isolated
4068            // store is not authoritative yet. Legacy plaintext/ciphertext may
4069            // remain on disk for recovery, but notification sinks must not use
4070            // it in this process.
4071            config.notifications.ntfy.token = None;
4072            config.notifications.bark.device_key = None;
4073        }
4074        // Merge the standalone connect.json (#455) onto `config.connect`,
4075        // migrating a legacy inline `connect` key from config.json (#453
4076        // state) when present. MUST run before the token hydration below so
4077        // it decrypts the post-merge ciphertext, not a stale/legacy copy.
4078        config.merge_connect_config(&data_dir);
4079        // One-time (idempotent) sweep of the rotated config.json.bak[.N]
4080        // generations for a legacy embedded `connect` sub-tree left behind by
4081        // a pre-#455 build (#468, follow-up to #457). Independent of whether
4082        // `merge_connect_config` just migrated the CURRENT config.json above —
4083        // an instance that was already migrated by an earlier run of this
4084        // binary has a clean config.json today but may still carry the
4085        // legacy key in an untouched `.bak`/`.bak.1`/`.bak.2` generation, since
4086        // backup rotation only overwrites those on a fresh SAVE. Runs on every
4087        // load but is cheap and a no-op once every generation has been swept.
4088        scrub_legacy_connect_from_config_backups(&data_dir);
4089        // Decrypt encrypted bamboo-connect platform tokens into in-memory plaintext.
4090        config.hydrate_connect_platform_tokens_from_encrypted();
4091        if provider_mcp_ready {
4092            if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
4093                tracing::warn!(error = %error, "connect credential hydration unavailable");
4094                for platform in &mut config.connect.platforms {
4095                    platform.token = None;
4096                    platform.app_secret = None;
4097                }
4098            }
4099        }
4100        if provider_mcp_ready {
4101            if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
4102                tracing::warn!(error = %error, "access-control credential hydration unavailable");
4103                config.clear_access_control_runtime_verifiers();
4104            }
4105        } else {
4106            config.clear_access_control_runtime_verifiers();
4107        }
4108        config.normalize_tool_settings();
4109        config.normalize_skill_settings();
4110        config.normalize_plugin_trust_settings();
4111
4112        // Legacy: `data_dir` is no longer a persisted config field. The data directory is
4113        // derived from runtime (BAMBOO_DATA_DIR or `${HOME}/.bamboo`).
4114        config.extra.remove("data_dir");
4115
4116        // Apply environment variable overrides (highest priority). Skipped by
4117        // one-shot CLI writers (`bamboo init` / `config set`) so transient
4118        // `BAMBOO_*` values are never baked into the persisted config.json.
4119        if apply_env {
4120            config.apply_env_overrides();
4121        }
4122
4123        // Publish env vars to the global cache so Bash tools can inject them —
4124        // ONLY when the caller owns that cache. Non-owning readers pass
4125        // publish=false so they don't clobber the server's live env-var cache.
4126        if publish {
4127            config.publish_env_vars();
4128        }
4129
4130        config
4131    }
4132
4133    /// Apply `BAMBOO_*` environment overrides (highest priority) onto a loaded
4134    /// config. Factored out so one-shot writers can skip it (see
4135    /// [`Config::from_data_dir_without_env`]).
4136    fn apply_env_overrides(&mut self) {
4137        if let Ok(port) = std::env::var("BAMBOO_PORT") {
4138            if let Ok(port) = port.parse() {
4139                self.server.port = port;
4140            }
4141        }
4142
4143        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
4144            self.server.bind = bind;
4145        }
4146
4147        // Note: BAMBOO_DATA_DIR already handled by the caller. In instance
4148        // mode the override may name either an exact instance id or a provider
4149        // type. Type matches are ordered by instance id so the result is
4150        // deterministic even for a multi-account configuration.
4151        if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
4152            let provider = provider.trim().to_string();
4153            if !provider.is_empty() {
4154                self.provider = provider.clone();
4155                if !self.provider_instances.is_empty() {
4156                    let selected = self
4157                        .provider_instances
4158                        .contains_key(&provider)
4159                        .then_some(provider.clone())
4160                        .or_else(|| {
4161                            let mut matching = self
4162                                .provider_instances
4163                                .iter()
4164                                .filter(|(_, instance)| {
4165                                    instance.enabled && instance.provider_type == provider
4166                                })
4167                                .map(|(id, _)| id.clone())
4168                                .collect::<Vec<_>>();
4169                            matching.sort();
4170                            matching.into_iter().next()
4171                        });
4172                    self.default_provider_instance = selected;
4173                }
4174            }
4175        }
4176
4177        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
4178            self.headless_auth = parse_bool_env(&headless);
4179        }
4180
4181        if let Ok(project_prompt_injection) =
4182            std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
4183        {
4184            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4185            memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
4186        }
4187
4188        if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
4189            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4190            memory.relevant_recall = parse_bool_env(&relevant_recall);
4191        }
4192
4193        if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4194            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4195            memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4196        }
4197
4198        if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4199            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4200            memory.project_first_dream = parse_bool_env(&project_first_dream);
4201        }
4202
4203        // Per-provider API keys from the environment (highest priority). Lets a
4204        // 12-factor / secret-manager / --env-file / k8s-Secret deploy supply the
4205        // key at runtime instead of baking a plaintext `api_key` into a mounted
4206        // config.json. The `api_key_from_env` flag keeps `refresh_provider_api_keys_encrypted`
4207        // from re-encrypting these keys into `api_key_encrypted` on a later save,
4208        // so an env key is never persisted to disk. (#253)
4209        if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4210            let key = key.trim();
4211            if !key.is_empty() {
4212                if self.provider_instances.is_empty() {
4213                    let openai = self
4214                        .providers
4215                        .openai
4216                        .get_or_insert_with(OpenAIConfig::default);
4217                    openai.api_key = key.to_string();
4218                    openai.api_key_from_env = true;
4219                } else {
4220                    self.apply_provider_instance_env_key("openai", key);
4221                }
4222            }
4223        }
4224        if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4225            let key = key.trim();
4226            if !key.is_empty() {
4227                if self.provider_instances.is_empty() {
4228                    let anthropic = self
4229                        .providers
4230                        .anthropic
4231                        .get_or_insert_with(AnthropicConfig::default);
4232                    anthropic.api_key = key.to_string();
4233                    anthropic.api_key_from_env = true;
4234                } else {
4235                    self.apply_provider_instance_env_key("anthropic", key);
4236                }
4237            }
4238        }
4239        if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4240            let key = key.trim();
4241            if !key.is_empty() {
4242                if self.provider_instances.is_empty() {
4243                    let gemini = self
4244                        .providers
4245                        .gemini
4246                        .get_or_insert_with(GeminiConfig::default);
4247                    gemini.api_key = key.to_string();
4248                    gemini.api_key_from_env = true;
4249                } else {
4250                    self.apply_provider_instance_env_key("gemini", key);
4251                }
4252            }
4253        }
4254    }
4255
4256    fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4257        for instance in self.provider_instances.values_mut().filter(|instance| {
4258            instance.provider_type == provider_type
4259                && crate::provider_instance_api_key_from_env(instance)
4260        }) {
4261            instance.api_key = key.to_string();
4262            instance.api_key_encrypted = None;
4263        }
4264    }
4265
4266    /// Apply runtime-only `BAMBOO_*` overrides to a config assembled by the
4267    /// modular facade. Facade callers use this after durable section snapshots
4268    /// and credential references have been materialized; one-shot writers keep
4269    /// using the no-env load path so overrides are never persisted.
4270    pub fn apply_runtime_env_overrides(&mut self) {
4271        self.apply_env_overrides();
4272    }
4273
4274    /// Load config from disk AND publish its env vars to the process-global cache
4275    /// (so Bash tools inject them). For the context that OWNS that cache — the
4276    /// server bootstrap. Library / secondary readers that only need to read a
4277    /// value must use [`Config::from_data_dir_without_publish`] instead, or they
4278    /// will clobber the server's live cache with stale disk data (#38 / #40).
4279    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4280        Self::from_data_dir_impl(data_dir, true, true)
4281    }
4282
4283    /// Load config from disk WITHOUT publishing env vars to the global cache.
4284    /// For non-owning readers (e.g. permission storage) that just need a config
4285    /// value and must not clobber the live env-var cache. #40.
4286    pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4287        Self::from_data_dir_impl(data_dir, false, true)
4288    }
4289
4290    /// Load config from disk WITHOUT applying `BAMBOO_*` env-var overrides and
4291    /// WITHOUT publishing to the global cache. For one-shot CLI writers
4292    /// (`bamboo init` / `config set`) that immediately re-save: applying env
4293    /// overrides here would bake transient values (port/bind/provider/memory
4294    /// flags) permanently into config.json. Same corruption-recovery + default
4295    /// fallback as the normal load.
4296    pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4297        Self::from_data_dir_impl(data_dir, false, false)
4298    }
4299
4300    /// Merge the standalone `connect.json` (#455) onto `self.connect`, the
4301    /// load-side counterpart of [`save_connect_config`]. Called once per load,
4302    /// BEFORE [`Config::hydrate_connect_platform_tokens_from_encrypted`] runs,
4303    /// so hydration decrypts the POST-merge ciphertext rather than a stale
4304    /// copy still embedded in `config.json`.
4305    ///
4306    /// - `connect.json` present & parseable: authoritative — OVERWRITES
4307    ///   whatever `self.connect` currently holds. If `self.connect` was ALSO
4308    ///   non-empty (a legacy inline `connect` key still in config.json, e.g.
4309    ///   #453-era state, or written by an older binary), that's a stale
4310    ///   duplicate: log a warning and proactively strip the superseded key
4311    ///   from config.json now (#457) rather than waiting for the next
4312    ///   natural save — cheap, and consistent with not spreading token
4313    ///   ciphertext across files.
4314    /// - `connect.json` present but corrupt/unparsable: fail SAFE for this
4315    ///   security-sensitive feature. Log an error, quarantine the bad file to
4316    ///   `connect.json.bak` (best-effort), and continue with an EMPTY
4317    ///   `ConnectConfig` — never falls back to a legacy config.json copy.
4318    /// - `connect.json` absent & `self.connect` non-empty (pure legacy
4319    ///   state): migrate proactively. Adopt the legacy value (already parsed
4320    ///   into `self`) and persist it: strip the `connect` key from
4321    ///   config.json and write connect.json (#457 — NOT a full
4322    ///   [`Config::save_to_dir`], which would re-encrypt every OTHER secret
4323    ///   in config.json and rotate its backups as a load-time side effect,
4324    ///   even for a read-only command like `bamboo config get`), logged at
4325    ///   info.
4326    /// - `connect.json` absent & `self.connect` empty: nothing to do.
4327    fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4328        let connect_path = data_dir.join("connect.json");
4329        match std::fs::read_to_string(&connect_path) {
4330            Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4331                Ok(connect) => {
4332                    let legacy_key_present = !connect_config_is_empty(&self.connect);
4333                    if legacy_key_present {
4334                        tracing::warn!(
4335                            "config.json still has a legacy `connect` key alongside \
4336                             connect.json; connect.json takes precedence — dropping the \
4337                             stale key from config.json now"
4338                        );
4339                    }
4340                    self.connect = connect;
4341                    if legacy_key_present {
4342                        strip_legacy_connect_key_from_config_json(data_dir);
4343                    }
4344                }
4345                Err(e) => {
4346                    tracing::error!(
4347                        "Failed to parse {:?} ({}); continuing with an empty (inert) \
4348                         connect config instead of falling back to a legacy config.json copy",
4349                        connect_path,
4350                        e
4351                    );
4352                    quarantine_corrupt_connect(&connect_path);
4353                    self.connect = ConnectConfig::default();
4354                }
4355            },
4356            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4357                if !connect_config_is_empty(&self.connect) {
4358                    tracing::info!(
4359                        "Migrating legacy `connect` config from config.json to a \
4360                         standalone connect.json"
4361                    );
4362                    // Narrow migration write (#457): strip only the `connect` key
4363                    // from config.json and write connect.json directly, instead of
4364                    // routing through a full `save_to_dir` (see doc comment above).
4365                    strip_legacy_connect_key_from_config_json(data_dir);
4366                    if let Err(e) = save_connect_config(&self.connect, data_dir) {
4367                        tracing::error!("Failed to write connect.json during migration: {}", e);
4368                    }
4369                }
4370            }
4371            Err(e) => {
4372                tracing::error!(
4373                    "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4374                    connect_path,
4375                    e
4376                );
4377                self.connect = ConnectConfig::default();
4378            }
4379        }
4380    }
4381
4382    /// Deserialize config JSON and run the in-memory hydration + normalization
4383    /// chain. Shared by the primary load and the backup-recovery path (#37).
4384    fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4385        serde_json::from_str::<Config>(content).map(|mut config| {
4386            config.hydrate_proxy_auth_from_encrypted();
4387            config.hydrate_provider_api_keys_from_encrypted();
4388            config.hydrate_provider_instance_api_keys_from_encrypted();
4389            config.hydrate_mcp_secrets_from_encrypted();
4390            config.hydrate_env_vars_from_encrypted();
4391            config.hydrate_cluster_fabric_from_encrypted();
4392            config.hydrate_broker_token_from_encrypted();
4393            config.hydrate_notifications_from_encrypted();
4394            config.hydrate_connect_platform_tokens_from_encrypted();
4395            config.normalize_tool_settings();
4396            config.normalize_skill_settings();
4397            config
4398        })
4399    }
4400
4401    /// Try to recover from the rotated `config.json.bak[.N]` generations (each a
4402    /// last-known-good written before a save) when the primary `config.json` is
4403    /// corrupt. Walks newest -> oldest and returns the first that parses (paired
4404    /// with its generation index, 0 == `.bak`, for [`ConfigRecoverySource::Backup`]);
4405    /// `None` if every generation is missing or also unparseable. #37 / #135.
4406    fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4407        let config_path = data_dir.join("config.json");
4408        for gen in 0..BAK_GENERATIONS {
4409            let backup = backup_path_for(&config_path, gen);
4410            let Ok(content) = std::fs::read_to_string(&backup) else {
4411                continue;
4412            };
4413            match Self::parse_and_hydrate(&content) {
4414                Ok(config) => {
4415                    tracing::info!("Recovered configuration from {:?}", backup);
4416                    return Some((config, gen));
4417                }
4418                Err(e) => {
4419                    tracing::warn!(
4420                        "Backup {:?} is unparseable ({}); trying an older generation",
4421                        backup,
4422                        e
4423                    );
4424                }
4425            }
4426        }
4427        None
4428    }
4429
4430    /// Largest corrupt-object key count we'll attempt to salvage. The overlay loop
4431    /// is O(keys) full-Config deserializes over a growing object (the `extra`
4432    /// catch-all absorbs unknown keys), i.e. O(n²) on a pathological file; cap it
4433    /// so a junk-key-flooded config.json can't stall a load. A real config has a
4434    /// few dozen top-level keys, so this only ever trips on garbage.
4435    const SALVAGE_MAX_KEYS: usize = 512;
4436
4437    /// Best-effort PARTIAL salvage of a corrupt `config.json` (#135): parse it as a
4438    /// generic JSON object and overlay each top-level field onto the richest
4439    /// known-good baseline — the last-known-good `config.json.bak` if present, else
4440    /// a fresh default — keeping only the fields that still yield a valid [`Config`].
4441    /// A single bad field (wrong type, malformed section, …) then keeps the
4442    /// baseline's value instead of discarding ALL the user's other settings.
4443    ///
4444    /// Overlaying onto `.bak` (rather than defaults) means the result is the
4445    /// best-of-both: the backup's complete recent-good state PLUS the corrupt
4446    /// file's still-valid newer edits on top — so salvage is never worse than the
4447    /// plain `.bak` fallback, removing the "sparse salvage defeats a rich backup"
4448    /// hazard. Tried BEFORE the bare `.bak` fallback.
4449    ///
4450    /// Returns the hydrated salvaged config, or `None` when the corrupt file isn't
4451    /// even a JSON object (nothing field-wise to salvage) so the caller falls
4452    /// through to `.bak` / defaults.
4453    ///
4454    /// NOTE: the per-field overlay guarantees a VALID `Config`, not a *maximal* or
4455    /// attribution-perfect one. Deterministic alphabetical key order (serde_json is
4456    /// BTreeMap-backed, no `preserve_order`) means a rename/alias pair like
4457    /// `mcp`/`mcpServers` can drop the second-seen even if it'd be valid alone — the
4458    /// outcome is still a valid config, just not necessarily the richest possible.
4459    ///
4460    /// Returns the hydrated salvaged config paired with the top-level keys that
4461    /// were actually recovered from the corrupt document (used to populate
4462    /// [`ConfigRecoverySource::Salvaged`]).
4463    fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4464        // Must at least be a JSON object; otherwise there's nothing field-wise to
4465        // salvage (a truncated/garbage file just falls through to .bak/defaults).
4466        let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4467        let corrupt_obj = corrupt.as_object()?;
4468        if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4469            tracing::warn!(
4470                "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4471                corrupt_obj.len(),
4472                Self::SALVAGE_MAX_KEYS
4473            );
4474            return None;
4475        }
4476
4477        // Overlay onto the richest known-good baseline: the last-known-good backup
4478        // if it parses, else a fresh default. This makes salvage >= the plain .bak
4479        // fallback in every case.
4480        let mut base = Self::load_backup(data_dir)
4481            .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4482            .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4483        let base_obj = base.as_object_mut()?;
4484
4485        let mut salvaged: Vec<String> = Vec::new();
4486        for (key, value) in corrupt_obj {
4487            let previous = base_obj.insert(key.clone(), value.clone());
4488            // Keep the field iff the WHOLE config still deserializes with it
4489            // overlaid — base is valid before each step, so a failure isolates THIS
4490            // field as the corrupt one (and inter-field constraints are respected).
4491            if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4492                salvaged.push(key.clone());
4493            } else {
4494                match previous {
4495                    Some(prev) => {
4496                        base_obj.insert(key.clone(), prev);
4497                    }
4498                    None => {
4499                        base_obj.remove(key);
4500                    }
4501                }
4502            }
4503        }
4504
4505        tracing::warn!(
4506            "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4507             last-known-good/default value",
4508            salvaged.len(),
4509            salvaged.join(", ")
4510        );
4511
4512        // Re-serialize the rebuilt (all-valid) object and run it back through the
4513        // normal parse+hydrate path so secret-decryption / normalization match a
4514        // clean load exactly.
4515        let rebuilt = serde_json::to_string(&base).ok()?;
4516        Self::parse_and_hydrate(&rebuilt)
4517            .ok()
4518            .map(|config| (config, salvaged))
4519    }
4520
4521    /// Get the effective default model for the currently active provider.
4522    ///
4523    /// When `features.provider_model_ref` is enabled, reads from `defaults.chat`
4524    /// before falling back to legacy provider-specific config.
4525    ///
4526    /// Note: for most providers this is a required config value (returns None when absent).
4527    /// Copilot has a built-in fallback when no model is configured.
4528    pub fn get_model(&self) -> Option<String> {
4529        if self.features.provider_model_ref {
4530            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4531                return Some(model_ref.model.clone());
4532            }
4533        }
4534        let provider = self.effective_default_provider();
4535        if let Some(instance) = self.provider_instances.get(provider) {
4536            return instance
4537                .model
4538                .clone()
4539                .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4540        }
4541        match provider {
4542            "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4543            "anthropic" => self
4544                .providers
4545                .anthropic
4546                .as_ref()
4547                .and_then(|c| c.model.clone()),
4548            "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4549            "copilot" => Some(
4550                self.providers
4551                    .copilot
4552                    .as_ref()
4553                    .and_then(|c| c.model.clone())
4554                    .unwrap_or_else(|| "gpt-4o".to_string()),
4555            ),
4556            _ => None,
4557        }
4558    }
4559
4560    /// Get the fast/cheap model for the currently active provider.
4561    ///
4562    /// When `features.provider_model_ref` is enabled, reads from `defaults.fast`
4563    /// before falling back to legacy provider-specific config.
4564    ///
4565    /// Used for lightweight tasks like title generation and summarization.
4566    /// Falls back to `get_model()` when no fast_model is configured.
4567    pub fn get_fast_model(&self) -> Option<String> {
4568        if self.features.provider_model_ref {
4569            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4570                return Some(model_ref.model.clone());
4571            }
4572        }
4573        let provider = self.effective_default_provider();
4574        let fast = if let Some(instance) = self.provider_instances.get(provider) {
4575            instance.fast_model.clone()
4576        } else {
4577            match provider {
4578                "openai" => self
4579                    .providers
4580                    .openai
4581                    .as_ref()
4582                    .and_then(|c| c.fast_model.clone()),
4583                "anthropic" => self
4584                    .providers
4585                    .anthropic
4586                    .as_ref()
4587                    .and_then(|c| c.fast_model.clone()),
4588                "gemini" => self
4589                    .providers
4590                    .gemini
4591                    .as_ref()
4592                    .and_then(|c| c.fast_model.clone()),
4593                "copilot" => self
4594                    .providers
4595                    .copilot
4596                    .as_ref()
4597                    .and_then(|c| c.fast_model.clone()),
4598                _ => None,
4599            }
4600        };
4601        fast.or_else(|| self.get_model())
4602    }
4603
4604    /// Get the configured task summarization model.
4605    ///
4606    /// When `features.provider_model_ref` is enabled, reads from
4607    /// `defaults.task_summary` before falling back through
4608    /// `defaults.fast` → `defaults.chat`.
4609    ///
4610    /// This is used for conversation/task summarization and context compression.
4611    pub fn get_task_summary_model(&self) -> Option<String> {
4612        if self.features.provider_model_ref {
4613            if let Some(model_ref) = self
4614                .defaults
4615                .as_ref()
4616                .and_then(|d| d.task_summary.as_ref())
4617                .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4618                .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4619            {
4620                return Some(model_ref.model.clone());
4621            }
4622        }
4623
4624        self.get_fast_model()
4625    }
4626
4627    /// Get the configured memory/background summarization model.
4628    ///
4629    /// When `features.provider_model_ref` is enabled, reads from
4630    /// `defaults.memory_background` before falling back through
4631    /// `defaults.fast` → `defaults.chat`.
4632    ///
4633    /// Legacy configuration follows the same dedicated → fast → chat order.
4634    pub fn get_memory_background_model(&self) -> Option<String> {
4635        if self.features.provider_model_ref {
4636            if let Some(model_ref) = self
4637                .defaults
4638                .as_ref()
4639                .and_then(|d| d.memory_background.as_ref())
4640            {
4641                return Some(model_ref.model.clone());
4642            }
4643            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4644                return Some(model_ref.model.clone());
4645            }
4646            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4647                return Some(model_ref.model.clone());
4648            }
4649        }
4650        let configured = self
4651            .memory
4652            .as_ref()
4653            .and_then(|memory| memory.background_model.as_ref())
4654            .map(|value| value.trim())
4655            .filter(|value| !value.is_empty())
4656            .map(ToString::to_string);
4657        configured.or_else(|| self.get_fast_model())
4658    }
4659
4660    /// Resolve the configured default work area path when present.
4661    ///
4662    /// This validates that the configured directory exists, but intentionally
4663    /// returns the stable expanded path rather than the platform-specific
4664    /// canonicalized path. On macOS, `canonicalize()` may rewrite `/var/...`
4665    /// to `/private/var/...`, which is correct at the filesystem layer but
4666    /// undesirable as a user-facing/config-derived workspace path.
4667    pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4668        let raw = self
4669            .default_work_area
4670            .as_ref()
4671            .and_then(|config| config.path.as_ref())
4672            .map(|value| value.trim())
4673            .filter(|value| !value.is_empty())?;
4674
4675        let candidate = expand_user_path(raw);
4676        if candidate.is_absolute() {
4677            let canonical = std::fs::canonicalize(&candidate).ok();
4678            return canonical
4679                .as_ref()
4680                .filter(|path| path.is_dir())
4681                .map(|_| candidate.clone())
4682                .or_else(|| candidate.is_dir().then_some(candidate));
4683        }
4684
4685        let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4686        let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4687        canonical
4688            .as_ref()
4689            .filter(|path| path.is_dir())
4690            .map(|_| from_bamboo_dir.clone())
4691            .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4692            .or_else(|| candidate.is_dir().then_some(candidate))
4693    }
4694
4695    /// Get the vision-capable model for the currently active provider.
4696    ///
4697    /// Used for image understanding tasks.
4698    /// Falls back to the fast model, then the chat model, when no vision model
4699    /// is configured.
4700    pub fn get_vision_model(&self) -> Option<String> {
4701        if self.features.provider_model_ref {
4702            if let Some(model_ref) = self
4703                .defaults
4704                .as_ref()
4705                .and_then(|defaults| defaults.vision.as_ref())
4706                .or_else(|| {
4707                    self.defaults
4708                        .as_ref()
4709                        .and_then(|defaults| defaults.fast.as_ref())
4710                })
4711                .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4712            {
4713                return Some(model_ref.model.clone());
4714            }
4715        }
4716        let provider = self.effective_default_provider();
4717        let vision = if let Some(instance) = self.provider_instances.get(provider) {
4718            instance.vision_model.clone()
4719        } else {
4720            match provider {
4721                "openai" => self
4722                    .providers
4723                    .openai
4724                    .as_ref()
4725                    .and_then(|c| c.vision_model.clone()),
4726                "anthropic" => self
4727                    .providers
4728                    .anthropic
4729                    .as_ref()
4730                    .and_then(|c| c.vision_model.clone()),
4731                "gemini" => self
4732                    .providers
4733                    .gemini
4734                    .as_ref()
4735                    .and_then(|c| c.vision_model.clone()),
4736                "copilot" => self
4737                    .providers
4738                    .copilot
4739                    .as_ref()
4740                    .and_then(|c| c.vision_model.clone()),
4741                _ => None,
4742            }
4743        };
4744        vision.or_else(|| self.get_fast_model())
4745    }
4746
4747    /// Get the default reasoning effort for the chat role.
4748    ///
4749    /// A role-specific preference wins over the provider-instance default.
4750    pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4751        if self.features.provider_model_ref {
4752            if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4753                return chat
4754                    .reasoning_effort
4755                    .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4756            }
4757        }
4758        self.reasoning_effort_for_key(self.effective_default_provider())
4759    }
4760
4761    /// Resolve the configured default reasoning effort for a provider routing key.
4762    ///
4763    /// The key may be a multi-instance provider id (for example `"copilot-work"`)
4764    /// or a legacy provider type (for example `"openai"`). In multi-instance mode
4765    /// the per-instance `reasoning_effort` lives under `provider_instances[<id>]`,
4766    /// so we resolve instance ids there first; otherwise we fall back to the
4767    /// legacy per-provider config. Both the execute path
4768    /// ([`crate`]'s `get_reasoning_effort_for_provider`) and the session-create
4769    /// path ([`Self::get_reasoning_effort`]) delegate here so the two cannot drift.
4770    pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4771        let trimmed = key.trim();
4772        if trimmed.is_empty() {
4773            return None;
4774        }
4775
4776        // Multi-instance mode: the routing key is an instance id.
4777        if let Some(instance) = self.provider_instances.get(trimmed) {
4778            return instance.reasoning_effort;
4779        }
4780
4781        // Legacy mode: the routing key is a provider type.
4782        match trimmed {
4783            "openai" => self
4784                .providers
4785                .openai
4786                .as_ref()
4787                .and_then(|c| c.reasoning_effort),
4788            "anthropic" => self
4789                .providers
4790                .anthropic
4791                .as_ref()
4792                .and_then(|c| c.reasoning_effort),
4793            "gemini" => self
4794                .providers
4795                .gemini
4796                .as_ref()
4797                .and_then(|c| c.reasoning_effort),
4798            "copilot" => self
4799                .providers
4800                .copilot
4801                .as_ref()
4802                .and_then(|c| c.reasoning_effort),
4803            "bodhi" => self
4804                .providers
4805                .bodhi
4806                .as_ref()
4807                .and_then(|c| c.reasoning_effort),
4808            _ => None,
4809        }
4810    }
4811
4812    /// Get exact disabled tool references for catalog-aware resolution.
4813    ///
4814    /// References remain exact here: catalog-aware filtering resolves an exact
4815    /// registered name before applying legacy/builtin alias fallback. Eagerly
4816    /// rewriting `apply_patch` to `Edit`, for example, would make an exact
4817    /// custom `apply_patch` registration indistinguishable from the builtin.
4818    pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4819        self.tools
4820            .disabled
4821            .iter()
4822            .map(|name| name.trim())
4823            .filter(|name| !name.is_empty())
4824            .map(str::to_string)
4825            .collect()
4826    }
4827
4828    /// Legacy normalized-name facade retained for source compatibility.
4829    ///
4830    /// New execution/catalog code must use [`Self::disabled_tool_references`]
4831    /// so an exact registered alias can be resolved before fallback.
4832    pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4833        self.disabled_tool_references()
4834            .into_iter()
4835            .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4836            .collect()
4837    }
4838
4839    /// Normalize tool settings (trim / dedupe / sort).
4840    pub fn normalize_tool_settings(&mut self) {
4841        self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4842    }
4843
4844    /// Get normalized disabled skill IDs.
4845    pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4846        self.skills
4847            .disabled
4848            .iter()
4849            .map(|id| id.trim())
4850            .filter(|id| !id.is_empty())
4851            .map(|id| id.to_string())
4852            .collect()
4853    }
4854
4855    /// Normalize skill settings (trim / dedupe / sort).
4856    pub fn normalize_skill_settings(&mut self) {
4857        self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4858    }
4859
4860    /// Normalize `plugin_trust.trusted_hosts` entries (trim / lowercase / drop
4861    /// empties) so a hand-edited `config.json` doesn't silently accumulate
4862    /// mixed-case or whitespace-padded entries. [`is_host_trusted`] itself
4863    /// already matches case-insensitively regardless of how an entry is
4864    /// stored, so this is defense in depth / a canonical on-disk form, not
4865    /// the source of the security fix — that's the host/path-component
4866    /// matching in [`is_host_trusted`] itself.
4867    pub fn normalize_plugin_trust_settings(&mut self) {
4868        self.plugin_trust.trusted_hosts = self
4869            .plugin_trust
4870            .trusted_hosts
4871            .iter()
4872            .map(|entry| entry.trim().to_ascii_lowercase())
4873            .filter(|entry| !entry.is_empty())
4874            .collect();
4875    }
4876
4877    /// Return the effective default provider key.
4878    ///
4879    /// Prefers `default_provider_instance` when set; falls back to the
4880    /// legacy `provider` string.
4881    pub fn effective_default_provider(&self) -> &str {
4882        self.default_provider_instance
4883            .as_deref()
4884            .unwrap_or(&self.provider)
4885    }
4886
4887    /// Whether provider instances are configured (new multi-instance path).
4888    pub fn has_provider_instances(&self) -> bool {
4889        !self.provider_instances.is_empty()
4890    }
4891
4892    /// Build a flat map of all env vars with non-empty values (for process injection).
4893    pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4894        self.env_vars
4895            .iter()
4896            .filter(|e| !e.value.trim().is_empty())
4897            .map(|e| (e.name.clone(), e.value.clone()))
4898            .collect()
4899    }
4900
4901    fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4902        self.env_vars
4903            .iter()
4904            .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4905            .map(|entry| PromptSafeEnvVarEntry {
4906                name: entry.name.clone(),
4907                secret: entry.secret,
4908                description: entry
4909                    .description
4910                    .as_ref()
4911                    .map(|value| value.trim().to_string())
4912                    .filter(|value| !value.is_empty()),
4913            })
4914            .collect()
4915    }
4916
4917    /// Update the global env vars cache (called on config load / reload).
4918    pub fn publish_env_vars(&self) {
4919        let map = self.env_vars_as_map();
4920
4921        #[cfg(any(test, feature = "test-utils"))]
4922        if crate::test_support::env_vars_cache_override_is_active() {
4923            crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4924            return;
4925        }
4926
4927        let mut env_guard = env_vars_cache().write().recover_poison();
4928        *env_guard = map;
4929
4930        let prompt_safe = self.prompt_safe_env_vars();
4931        let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4932        *prompt_guard = prompt_safe;
4933    }
4934
4935    /// Read the current env vars snapshot (called by Bash tool at process spawn time).
4936    pub fn current_env_vars() -> HashMap<String, String> {
4937        #[cfg(any(test, feature = "test-utils"))]
4938        if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4939            return env_vars;
4940        }
4941
4942        env_vars_cache().read().recover_poison().clone()
4943    }
4944
4945    /// Read the current prompt-safe env var snapshot (names + metadata only; no secret values).
4946    pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4947        #[cfg(any(test, feature = "test-utils"))]
4948        if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4949            return env_vars;
4950        }
4951
4952        prompt_safe_env_vars_cache().read().recover_poison().clone()
4953    }
4954
4955    /// Create a default configuration without loading from file
4956    fn create_default() -> Self {
4957        Self::from_parts(
4958            ConfigValues {
4959                http_proxy: String::new(),
4960                https_proxy: String::new(),
4961                proxy_auth: None,
4962                proxy_auth_encrypted: None,
4963                proxy_auth_credential_ref: None,
4964                headless_auth: false,
4965                run_budget: RunBudgetConfig::default(),
4966                stream_timeout: StreamTimeoutConfig::default(),
4967                llm_retry: None,
4968                context_management: ContextManagementConfig::default(),
4969                cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4970                provider: default_provider(),
4971                provider_instances: HashMap::new(),
4972                default_provider_instance: None,
4973                server: ServerConfig::default(),
4974                keyword_masking: KeywordMaskingConfig::default(),
4975                anthropic_model_mapping: AnthropicModelMapping::default(),
4976                gemini_model_mapping: GeminiModelMapping::default(),
4977                patrol_model: None,
4978                hooks: HooksConfig::default(),
4979                lifecycle_hooks: LifecycleHooksConfig::default(),
4980                tools: ToolsConfig::default(),
4981                skills: SkillsConfig::default(),
4982                env_vars: Vec::new(),
4983                default_work_area: None,
4984                worktrees: WorktreeSettings::default(),
4985                access_control: None,
4986                features: FeatureFlags::default(),
4987                defaults: None,
4988                mcp: bamboo_domain::mcp_config::McpConfig::default(),
4989                notifications: NotificationsConfig::default(),
4990                connect: ConnectConfig::default(),
4991                plugin_trust: PluginTrustConfig::default(),
4992                extra: BTreeMap::new(),
4993            },
4994            None,
4995            SubagentsConfig::default(),
4996            ProviderConfigs::default(),
4997        )
4998    }
4999
5000    /// Get the full server address (bind:port)
5001    pub fn server_addr(&self) -> String {
5002        format!("{}:{}", self.server.bind, self.server.port)
5003    }
5004
5005    /// Save configuration to disk
5006    pub fn save(&self) -> Result<()> {
5007        self.save_to_dir(default_data_dir())
5008    }
5009
5010    /// Persist only the memory module, leaving every other config file untouched.
5011    /// Legacy layouts only; modular layouts require a revisioned section update.
5012    pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5013        self.memory.save_sync(data_dir)
5014    }
5015
5016    /// Persist only the sub-agent module, leaving every other config file untouched.
5017    /// Legacy layouts only; modular layouts require a revisioned section update.
5018    pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5019        self.subagents.save_sync(data_dir)
5020    }
5021
5022    /// Persist only provider configuration. Provider plaintext keys are first
5023    /// refreshed into their encrypted at-rest representation.
5024    /// Legacy layouts only; modular layouts require a revisioned section update.
5025    pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5026        crate::config_module::ensure_legacy_module_layout(data_dir)?;
5027        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5028        let mut config = self.clone();
5029        config.clear_legacy_provider_aliases_for_instance_mode();
5030        config.refresh_provider_api_keys_encrypted()?;
5031        config.providers.save_sync(data_dir)
5032    }
5033
5034    /// Build the metadata-only documents used by a provider credential
5035    /// transaction. Nothing is written here: the migration journal owns the
5036    /// durable commit and publishes both documents together with credentials.
5037    pub(crate) fn prepare_provider_transaction_documents(
5038        &self,
5039        provider_document: &[u8],
5040    ) -> Result<(Vec<u8>, Vec<u8>)> {
5041        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5042        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5043            anyhow::bail!(
5044                "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
5045                status.source
5046            );
5047        }
5048
5049        let mut to_save = self.clone();
5050        to_save.clear_legacy_provider_aliases_for_instance_mode();
5051        to_save.extra.remove("data_dir");
5052        to_save.extra.remove("model");
5053        to_save.refresh_encrypted_secrets()?;
5054        to_save.ensure_provider_instance_credentials_isolated()?;
5055        to_save.sanitize_mcp_credential_refs_for_disk();
5056        to_save.sanitize_env_vars_for_disk();
5057        to_save.sanitize_notifications_for_disk();
5058        to_save.sanitize_cluster_fabric_for_disk();
5059        to_save.assign_connect_platform_ids();
5060        to_save.normalize_tool_settings();
5061        to_save.normalize_skill_settings();
5062
5063        let mut root = durable_root_value(to_save.values.clone())
5064            .context("Failed to serialize root config DTO to JSON")?;
5065        if let Some(object) = root.as_object_mut() {
5066            object.remove("connect");
5067        }
5068        let root = serde_json::to_vec_pretty(&root)?;
5069
5070        let mut providers = to_save.providers.0.clone();
5071        macro_rules! sanitize_provider {
5072            ($field:ident) => {
5073                if let Some(provider) = providers.$field.as_mut() {
5074                    if !provider.api_key.trim().is_empty()
5075                        && !provider.api_key_from_env
5076                        && provider.credential_ref.is_none()
5077                    {
5078                        anyhow::bail!(
5079                            "provider secret requires credential transaction before persistence"
5080                        );
5081                    }
5082                    provider.api_key_encrypted = None;
5083                }
5084            };
5085        }
5086        sanitize_provider!(openai);
5087        sanitize_provider!(anthropic);
5088        sanitize_provider!(gemini);
5089        if let Some(provider) = providers.bodhi.as_mut() {
5090            if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
5091                anyhow::bail!("provider secret requires credential transaction before persistence");
5092            }
5093            provider.api_key_encrypted = None;
5094        }
5095
5096        let existing_provider_value = if provider_document.is_empty() {
5097            None
5098        } else {
5099            Some(
5100                serde_json::from_slice::<Value>(provider_document)
5101                    .context("provider metadata document is invalid")?,
5102            )
5103        };
5104        let provider_value = match existing_provider_value {
5105            Some(Value::Object(mut envelope))
5106                if envelope.contains_key("schema_version")
5107                    || envelope.contains_key("revision")
5108                    || envelope.contains_key("data") =>
5109            {
5110                let revision = envelope
5111                    .get("revision")
5112                    .and_then(Value::as_u64)
5113                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5114                let schema_version = envelope
5115                    .get("schema_version")
5116                    .and_then(Value::as_u64)
5117                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5118                if schema_version != 1 || !envelope.contains_key("data") {
5119                    anyhow::bail!("provider revision envelope is unsupported");
5120                }
5121                let revision = revision
5122                    .checked_add(1)
5123                    .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
5124                envelope.insert("revision".into(), Value::from(revision));
5125                envelope.insert("data".into(), serde_json::to_value(providers)?);
5126                Value::Object(envelope)
5127            }
5128            Some(_) | None => serde_json::to_value(providers)?,
5129        };
5130        Ok((root, serde_json::to_vec_pretty(&provider_value)?))
5131    }
5132
5133    /// The pending config-corruption recovery, if `config.json` failed to
5134    /// parse on load and the recovery hasn't been confirmed yet. `None` on
5135    /// every clean load. #153.
5136    pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
5137        self.recovery_status.as_ref()
5138    }
5139
5140    /// Confirm a pending recovery, allowing the next [`Config::save`] /
5141    /// [`Config::save_to_dir`] to overwrite the quarantined-corrupt
5142    /// `config.json` with this recovered state. No-op if there's no pending
5143    /// recovery. Prefer [`Config::confirm_recovery_and_save_to_dir`], which
5144    /// also persists and clears the flag in one step. #153.
5145    pub fn confirm_recovery(&mut self) {
5146        if let Some(status) = self.recovery_status.as_mut() {
5147            status.confirmed = true;
5148        }
5149    }
5150
5151    /// Confirm a pending recovery AND persist it in one step: marks it
5152    /// confirmed (satisfying the [`Config::save_to_dir`] guard), writes the
5153    /// recovered state to `config.json`, then clears `recovery_status`
5154    /// entirely — once this succeeds the config is no longer "pending
5155    /// confirmation", it's just the normal on-disk config. Errors (and
5156    /// leaves `recovery_status` untouched) if there's nothing pending, or if
5157    /// the save itself fails. #153.
5158    pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
5159        if self.recovery_status.is_none() {
5160            anyhow::bail!("No pending config-corruption recovery to confirm");
5161        }
5162        self.confirm_recovery();
5163        self.save_to_dir(data_dir)?;
5164        self.recovery_status = None;
5165        Ok(())
5166    }
5167
5168    /// Assign a stable [`ConnectPlatformConfig::id`] to every `connect.platforms`
5169    /// entry that doesn't already have one (#496).
5170    ///
5171    /// Migration-on-write: [`Config::save_to_dir`] always calls this on its
5172    /// internal save-copy before persisting, so every path that writes
5173    /// `connect.json` gets ids backfilled. Callers that mutate the *live*
5174    /// in-memory config as part of a save (e.g. the server's settings-PATCH
5175    /// handler) should also call this directly on that in-memory value
5176    /// before responding, so a client that echoes the response straight
5177    /// back round-trips the id immediately rather than only after the next
5178    /// reload/restart. Never called from load — a config that's never saved
5179    /// again (e.g. one sitting in an unconfirmed-recovery state, see #493)
5180    /// is never rewritten just to backfill ids. An entry that already has
5181    /// an id keeps it unchanged; ids are never reassigned or deduplicated
5182    /// once set.
5183    pub fn assign_connect_platform_ids(&mut self) {
5184        for platform in &mut self.connect.platforms {
5185            if platform.id.is_none() {
5186                platform.id = Some(uuid::Uuid::new_v4().to_string());
5187            }
5188        }
5189    }
5190
5191    /// Save configuration to disk under the provided data directory.
5192    ///
5193    /// Root configuration is stored as `{data_dir}/config.json`; extracted
5194    /// memory, sub-agent, and provider modules are stored in sibling sidecars.
5195    ///
5196    /// Refuses to write when this config carries an unconfirmed
5197    /// [`ConfigRecoveryStatus`] (#153) — i.e. it was recovered from a corrupt
5198    /// `config.json` and the recovery hasn't been confirmed — so a corrupt
5199    /// original a user might want to hand-fix is never silently clobbered by
5200    /// an auto-persisted recovery. Call [`Config::confirm_recovery`] (or
5201    /// [`Config::confirm_recovery_and_save_to_dir`]) first.
5202    pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5203        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5204        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5205            anyhow::bail!(
5206                "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5207                 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5208                 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5209                status.source,
5210                status.quarantine_path,
5211            );
5212        }
5213        if self.proxy_auth_credential_ref.is_none()
5214            && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5215        {
5216            anyhow::bail!(
5217                "proxy auth requires the isolated credential transaction before persistence"
5218            );
5219        }
5220        if self.env_vars.iter().any(|entry| {
5221            entry.secret
5222                && entry.credential_ref.is_none()
5223                && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5224        }) {
5225            anyhow::bail!(
5226                "secret env vars require the isolated credential transaction before persistence"
5227            );
5228        }
5229        if [
5230            (
5231                &self.notifications.ntfy.token,
5232                &self.notifications.ntfy.token_encrypted,
5233                &self.notifications.ntfy.credential_ref,
5234            ),
5235            (
5236                &self.notifications.bark.device_key,
5237                &self.notifications.bark.device_key_encrypted,
5238                &self.notifications.bark.credential_ref,
5239            ),
5240        ]
5241        .iter()
5242        .any(|(plaintext, ciphertext, reference)| {
5243            reference.is_none()
5244                && (plaintext
5245                    .as_deref()
5246                    .is_some_and(|value| !value.trim().is_empty())
5247                    || ciphertext.is_some())
5248        }) {
5249            anyhow::bail!(
5250                "notification secrets require the isolated credential transaction before persistence"
5251            );
5252        }
5253
5254        if crate::modular_authority_boundary_present(&data_dir)
5255            .context("Failed to inspect modular configuration authority boundary")?
5256        {
5257            crate::ConfigFacade::open_or_migrate(&data_dir)
5258                .context("Failed to recover modular configuration before persistence")?;
5259            crate::persist_facade_effective_config(&data_dir, self)
5260                .context("Failed to persist modular configuration sections")?;
5261            return Ok(());
5262        }
5263
5264        if crate::section_layout_is_active(&data_dir)
5265            .context("Failed to inspect modular configuration layout")?
5266        {
5267            crate::persist_facade_effective_config(&data_dir, self)
5268                .context("Failed to persist modular configuration sections")?;
5269            return Ok(());
5270        }
5271
5272        let path = data_dir.join("config.json");
5273
5274        if let Some(parent) = path.parent() {
5275            std::fs::create_dir_all(parent)
5276                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5277        }
5278
5279        let mut to_save = self.clone();
5280        to_save.clear_legacy_provider_aliases_for_instance_mode();
5281        // Never persist `data_dir` into config.json (data dir is runtime-derived).
5282        to_save.extra.remove("data_dir");
5283        // Root-level `model` is deprecated; do not persist it.
5284        to_save.extra.remove("model");
5285        // `subagents.broker` is `#[serde(skip)]` (runtime-only, lives in its own
5286        // broker.json / embedded in-process) — nothing to encrypt or persist here.
5287        to_save.refresh_encrypted_secrets()?;
5288        to_save.ensure_provider_instance_credentials_isolated()?;
5289        to_save.sanitize_mcp_credential_refs_for_disk();
5290        to_save.sanitize_env_vars_for_disk();
5291        to_save.sanitize_notifications_for_disk();
5292        to_save.sanitize_cluster_fabric_for_disk();
5293        to_save.assign_connect_platform_ids();
5294        to_save.normalize_tool_settings();
5295        to_save.normalize_skill_settings();
5296
5297        // Split `connect` (#455) out of the config.json document: bamboo-connect
5298        // platform-bridge credentials (bot tokens, allowlists) get their own
5299        // sibling file, connect.json (written below), instead of living in
5300        // config.json — different sensitivity/lifecycle. The `connect` FIELD on
5301        // `Config` keeps its normal serde shape unchanged (still required by the
5302        // settings API / `preserve_masked_connect_secrets`, which operate on the
5303        // in-memory struct) — only the serialized DOCUMENT that becomes
5304        // config.json's bytes has the key stripped, and that's done on the
5305        // `serde_json::Value` here, not via `#[serde(skip)]` on the field.
5306        let mut config_value = durable_root_value(to_save.values.clone())
5307            .context("Failed to serialize root config DTO to JSON")?;
5308        if let Some(obj) = config_value.as_object_mut() {
5309            obj.remove("connect");
5310        }
5311        let content = serde_json::to_string_pretty(&config_value)
5312            .context("Failed to serialize config to JSON")?;
5313
5314        // Persist extracted modules before stripping their legacy inline
5315        // representation from config.json. If the process crashes or the root
5316        // rewrite fails during the first migration, the next load can still use
5317        // either the new sidecars or the untouched inline values. Do this before
5318        // rotating root backups so a sidecar error cannot consume backup history
5319        // for a root document that was never rewritten.
5320        to_save.memory.save_sync(&data_dir)?;
5321        to_save.subagents.save_sync(&data_dir)?;
5322        to_save.providers.save_sync(&data_dir)?;
5323
5324        // Back up the current on-disk config (last-known-good) before overwriting,
5325        // so corruption (a bad/partial write, external edit, disk issue) stays
5326        // recoverable via config.json.bak on the next load. Best-effort. Only
5327        // refresh the backup from a PARSEABLE config.json — otherwise a save right
5328        // after an in-memory recovery (where the on-disk config.json is still the
5329        // corrupt original) would clobber the good .bak with garbage. #37.
5330        if path.exists()
5331            && std::fs::read_to_string(&path)
5332                .ok()
5333                .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5334        {
5335            // Rotate the older generations down (.bak -> .bak.1 -> .bak.2 …) so a
5336            // few last-known-good snapshots survive, then snapshot the current
5337            // (parseable) config.json as the freshest .bak. #135.
5338            rotate_backups(&path, BAK_GENERATIONS);
5339            let backup = backup_path_for(&path, 0);
5340            let backup_result = std::fs::read(&path).and_then(|bytes| {
5341                let mut value: Value = serde_json::from_slice(&bytes)
5342                    .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5343                if sanitize_ref_backed_mcp_json(&mut value) {
5344                    let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5345                        std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5346                    })?;
5347                    write_atomic(&backup, &sanitized)
5348                } else {
5349                    std::fs::copy(&path, &backup).map(|_| ())
5350                }
5351            });
5352            if let Err(e) = backup_result {
5353                tracing::warn!("Failed to back up config.json before save: {}", e);
5354            }
5355            scrub_ref_backed_mcp_from_config_backups(&path);
5356        }
5357
5358        write_atomic(&path, content.as_bytes())
5359            .with_context(|| format!("Failed to write config file: {:?}", path))?;
5360
5361        save_connect_config(&to_save.connect, &data_dir)?;
5362
5363        Ok(())
5364    }
5365}
5366
5367/// Remove credential-ref-backed MCP values from a raw root document without
5368/// otherwise normalizing its compatibility shape. This is used for rotated
5369/// root backups as well as the typed disk DTO so a pre-fix root cannot keep a
5370/// duplicate secret alive for several more saves.
5371fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5372    let Some(object) = root.as_object_mut() else {
5373        return false;
5374    };
5375    let Some(mcp) = (if object.contains_key("mcpServers") {
5376        object.get_mut("mcpServers")
5377    } else {
5378        object.get_mut("mcp")
5379    }) else {
5380        return false;
5381    };
5382    let mut changed = false;
5383    if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5384        for server in servers {
5385            if let Some(object) = server.as_object_mut() {
5386                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5387                {
5388                    changed |= sanitize_ref_backed_mcp_transport(transport);
5389                }
5390            }
5391        }
5392    } else if let Some(servers) = mcp.as_object_mut() {
5393        for server in servers.values_mut() {
5394            if let Some(object) = server.as_object_mut() {
5395                changed |= sanitize_ref_backed_mcp_transport(object);
5396                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5397                {
5398                    changed |= sanitize_ref_backed_mcp_transport(transport);
5399                }
5400            }
5401        }
5402    }
5403    changed
5404}
5405
5406fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5407    let mut changed = false;
5408    let env_names = object
5409        .get("env_credential_refs")
5410        .and_then(Value::as_object)
5411        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5412        .unwrap_or_default();
5413    for name in env_names {
5414        for field in ["env", "env_encrypted"] {
5415            if object
5416                .get_mut(field)
5417                .and_then(Value::as_object_mut)
5418                .and_then(|values| values.remove(&name))
5419                .is_some()
5420            {
5421                changed = true;
5422            }
5423        }
5424    }
5425    let header_names = object
5426        .get("header_credential_refs")
5427        .and_then(Value::as_object)
5428        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5429        .unwrap_or_default();
5430    for name in header_names {
5431        for field in ["headers", "headers_encrypted"] {
5432            if object
5433                .get_mut(field)
5434                .and_then(Value::as_object_mut)
5435                .and_then(|values| values.remove(&name))
5436                .is_some()
5437            {
5438                changed = true;
5439            }
5440        }
5441    }
5442    if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5443        for header in headers {
5444            let Some(header) = header.as_object_mut() else {
5445                continue;
5446            };
5447            if header.get("credential_ref").is_some_and(Value::is_string) {
5448                changed |= header.remove("value").is_some();
5449                changed |= header.remove("value_encrypted").is_some();
5450            }
5451        }
5452    }
5453    changed
5454}
5455
5456fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5457    for generation in 0..BAK_GENERATIONS {
5458        let backup = backup_path_for(path, generation);
5459        let Ok(bytes) = std::fs::read(&backup) else {
5460            continue;
5461        };
5462        let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5463            continue;
5464        };
5465        if sanitize_ref_backed_mcp_json(&mut value) {
5466            if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5467                if let Err(error) = write_atomic(&backup, &sanitized) {
5468                    tracing::warn!(
5469                        "Failed to scrub MCP credentials from {:?}: {}",
5470                        backup,
5471                        error
5472                    );
5473                }
5474            }
5475        }
5476    }
5477}
5478
5479/// Persist `connect` (#455) to its own sibling file, `connect.json`, next to
5480/// config.json — the save-side counterpart of [`Config::merge_connect_config`].
5481///
5482/// Only writes when the config is non-empty OR the file already exists, so a
5483/// fresh/default install with no platforms configured never gets a
5484/// `connect.json` littering its data dir. Before an existing file is
5485/// overwritten, it's copied aside to a single `connect.json.bak` generation
5486/// (best-effort) — connect.json doesn't need config.json's multi-generation
5487/// rotation, one last-known-good snapshot is enough.
5488fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5489    let path = data_dir.join("connect.json");
5490    if connect_config_is_empty(connect) && !path.exists() {
5491        return Ok(());
5492    }
5493
5494    if path.exists() {
5495        let backup = path.with_extension("json.bak");
5496        if let Err(e) = std::fs::copy(&path, &backup) {
5497            tracing::warn!("Failed to back up connect.json before save: {}", e);
5498        }
5499    }
5500
5501    let content = serde_json::to_string_pretty(connect)
5502        .context("Failed to serialize connect config to JSON")?;
5503    write_atomic(&path, content.as_bytes())
5504        .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5505    Ok(())
5506}
5507
5508/// Remove the legacy inline `connect` key from `config.json` on disk, if
5509/// present — the narrow, load-side counterpart of the full-document rewrite
5510/// [`Config::save_to_dir`] would otherwise perform just to drop one stale
5511/// key. Used by [`Config::merge_connect_config`] both when adopting a
5512/// pure-legacy `connect` key (migration) and when a stale legacy key lingers
5513/// alongside an authoritative connect.json. #457.
5514///
5515/// Operates on the raw `serde_json::Value` read straight from disk — NOT on
5516/// the typed `Config` — so it touches nothing but the one key: no other
5517/// secret gets re-encrypted, and no `config.json.bak` generation gets
5518/// rotated, as a side effect of a load.
5519///
5520/// Best-effort: read/parse/write failures are logged, not propagated — this
5521/// runs as a side effect of `Config::new()` / load, which has no `Result` to
5522/// surface it through. A failure here just leaves the stale key in place
5523/// until the next natural save; connect.json (written separately) is already
5524/// authoritative in memory either way.
5525fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5526    let config_path = data_dir.join("config.json");
5527    let content = match std::fs::read_to_string(&config_path) {
5528        Ok(content) => content,
5529        Err(e) => {
5530            tracing::error!(
5531                "Failed to read config.json to strip legacy `connect` key: {}",
5532                e
5533            );
5534            return;
5535        }
5536    };
5537    let mut value: serde_json::Value = match serde_json::from_str(&content) {
5538        Ok(value) => value,
5539        Err(e) => {
5540            tracing::error!(
5541                "Failed to parse config.json to strip legacy `connect` key: {}",
5542                e
5543            );
5544            return;
5545        }
5546    };
5547    let Some(obj) = value.as_object_mut() else {
5548        return;
5549    };
5550    if obj.remove("connect").is_none() {
5551        // Nothing to strip (e.g. raced with a concurrent save that already
5552        // dropped it) — avoid an unnecessary rewrite.
5553        return;
5554    }
5555    let rewritten = match serde_json::to_string_pretty(&value) {
5556        Ok(rewritten) => rewritten,
5557        Err(e) => {
5558            tracing::error!(
5559                "Failed to serialize config.json after stripping legacy `connect` key: {}",
5560                e
5561            );
5562            return;
5563        }
5564    };
5565    if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5566        tracing::error!(
5567            "Failed to write config.json after stripping legacy `connect` key: {}",
5568            e
5569        );
5570    }
5571}
5572
5573/// Sweep the rotated `config.json.bak[.N]` generations for a legacy embedded
5574/// `connect` sub-tree that predates the #455 connect.json split, and strip it
5575/// in place. #468 (follow-up to #457).
5576///
5577/// `strip_legacy_connect_key_from_config_json` only ever rewrites the CURRENT
5578/// `config.json` — it never reaches into `.bak` generations, and the normal
5579/// backup-rotation path (see [`rotate_backups`]) only overwrites a `.bak[.N]`
5580/// slot as a side effect of a fresh SAVE. An instance that upgraded from a
5581/// pre-#455 build but rarely (or never) triggers a config save can therefore
5582/// carry the legacy, encrypted `connect` sub-tree — including bot tokens, an
5583/// immediately-usable remote-control credential — in an old backup generation
5584/// indefinitely, even after its live config.json has long since been
5585/// migrated.
5586///
5587/// Deliberately surgical, mirroring the `.bak` files' role as the user's
5588/// recovery net (#493's "backups are a low-sensitivity snapshot, don't fuss
5589/// with them" posture):
5590/// - a generation that doesn't exist, or that fails to parse as JSON, is
5591///   SKIPPED — logged, never deleted, never guessed at. Corrupt/foreign
5592///   content in a `.bak` slot is left exactly as found for hand inspection.
5593/// - a generation that parses but carries no `connect` key (the overwhelming
5594///   majority, especially on any instance that predates this fix by more
5595///   than `BAK_GENERATIONS` saves) is left COMPLETELY untouched — not even a
5596///   byte-identical rewrite — so its mtime and on-disk bytes survive.
5597/// - only a generation that actually parses AND carries the legacy key gets
5598///   rewritten, via the same key-removal-on-the-raw-`Value` + `write_atomic`
5599///   approach as `strip_legacy_connect_key_from_config_json`, so every other
5600///   byte of that snapshot (all other settings, formatting aside) survives.
5601///
5602/// Runs unconditionally on every load (not gated on the CURRENT config.json
5603/// still carrying the legacy key) specifically to catch already-migrated
5604/// installs whose backups predate this fix. Cheap: at most `BAK_GENERATIONS`
5605/// small file reads, and a genuine no-op (zero writes) once every generation
5606/// has been swept once. Best-effort like its sibling: failures are logged,
5607/// not propagated, since this runs as a side effect of `Config::new()` /
5608/// load, which has no `Result` to surface it through.
5609fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5610    let config_path = data_dir.join("config.json");
5611    for gen in 0..BAK_GENERATIONS {
5612        let backup = backup_path_for(&config_path, gen);
5613        let content = match std::fs::read_to_string(&backup) {
5614            Ok(content) => content,
5615            Err(e) => {
5616                if e.kind() != std::io::ErrorKind::NotFound {
5617                    tracing::warn!(
5618                        "Failed to read {:?} while scanning for legacy connect data ({}); \
5619                         leaving it untouched",
5620                        backup,
5621                        e
5622                    );
5623                }
5624                continue;
5625            }
5626        };
5627        let mut value: serde_json::Value = match serde_json::from_str(&content) {
5628            Ok(value) => value,
5629            Err(e) => {
5630                tracing::warn!(
5631                    "Skipping unparsable backup {:?} while scanning for legacy connect data \
5632                     ({}); left untouched (never deleted)",
5633                    backup,
5634                    e
5635                );
5636                continue;
5637            }
5638        };
5639        let Some(obj) = value.as_object_mut() else {
5640            // Not a JSON object (e.g. `null`/an array) — nothing to strip, and
5641            // not a shape we should try to rewrite. Leave it alone.
5642            continue;
5643        };
5644        if obj.remove("connect").is_none() {
5645            // No legacy key in this generation — skip without writing so the
5646            // file's bytes/mtime are left completely untouched.
5647            continue;
5648        }
5649        let rewritten = match serde_json::to_string_pretty(&value) {
5650            Ok(rewritten) => rewritten,
5651            Err(e) => {
5652                tracing::error!(
5653                    "Failed to serialize {:?} after stripping legacy connect data: {}",
5654                    backup,
5655                    e
5656                );
5657                continue;
5658            }
5659        };
5660        match write_atomic(&backup, rewritten.as_bytes()) {
5661            Ok(()) => tracing::info!(
5662                "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5663                backup
5664            ),
5665            Err(e) => tracing::error!(
5666                "Failed to write {:?} after stripping legacy connect data: {}",
5667                backup,
5668                e
5669            ),
5670        }
5671    }
5672}
5673
5674/// Quarantine an unparsable `connect.json` to a single `connect.json.bak`
5675/// generation (best-effort) so the bad content survives for inspection
5676/// instead of being silently discarded. Unlike config.json's timestamped,
5677/// N-generation quarantine, connect.json only needs one slot — it's a much
5678/// smaller, less complex document and this is a fail-SAFE (empty/inert
5679/// bridge), not a fail-recover, posture. #455.
5680///
5681/// MOVES the corrupt file rather than copying it (#457): a copy would leave
5682/// the same corrupt `connect.json` sitting in the data dir right next to its
5683/// own quarantine copy, which reads as confusing/ambiguous mid-incident
5684/// (which one is live?). `rename` is used first (atomic, no partial-copy
5685/// window); if that fails — e.g. `connect.json.bak` and the data dir are on
5686/// different filesystems — fall back to copy-then-remove so the corrupt
5687/// original still doesn't linger.
5688fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5689    let backup = connect_path.with_extension("json.bak");
5690    match std::fs::rename(connect_path, &backup) {
5691        Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5692        Err(e) => {
5693            tracing::warn!(
5694                "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5695                backup,
5696                e
5697            );
5698            if let Err(e) = std::fs::copy(connect_path, &backup) {
5699                tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5700                return;
5701            }
5702            if let Err(e) = std::fs::remove_file(connect_path) {
5703                tracing::error!(
5704                    "Quarantined corrupt connect.json to {:?} but failed to remove the \
5705                     original {:?}: {}",
5706                    backup,
5707                    connect_path,
5708                    e
5709                );
5710            }
5711        }
5712    }
5713}
5714
5715/// How many `config.json.corrupted.*` quarantine files to keep. Each corrupt load
5716/// drops one; without a cap they accumulate unbounded. Newest `N` are retained.
5717const QUARANTINE_KEEP: usize = 5;
5718
5719/// Copy a corrupt config file aside to `config.json.corrupted.<nanos>` so the
5720/// user's (unparseable) configuration is preserved for inspection/recovery
5721/// instead of being silently discarded and then overwritten by defaults. #37.
5722///
5723/// Returns the quarantine path on success, so the caller can attach it to a
5724/// [`ConfigRecoveryStatus`] (#153); `None` if even the copy failed (the
5725/// corrupt original is still left in place at `config_path` regardless, since
5726/// this only ever copies, never moves/deletes).
5727fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5728    let nanos = std::time::SystemTime::now()
5729        .duration_since(std::time::UNIX_EPOCH)
5730        .map(|d| d.as_nanos())
5731        .unwrap_or(0);
5732    // Two corrupt loads in the same nanosecond would land on the same name and the
5733    // second `copy` would silently overwrite the first. Append a counter on
5734    // collision so each quarantine is preserved distinctly. #135.
5735    let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5736    let mut dedup = 1u32;
5737    while quarantine.exists() {
5738        quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5739        dedup += 1;
5740    }
5741    let result = match std::fs::copy(config_path, &quarantine) {
5742        Ok(_) => {
5743            tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5744            Some(quarantine)
5745        }
5746        Err(e) => {
5747            tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5748            None
5749        }
5750    };
5751    prune_quarantine_files(config_path, QUARANTINE_KEEP);
5752    result
5753}
5754
5755/// Keep only the newest `keep` `config.json.corrupted.*` files next to
5756/// `config_path`, deleting older ones so quarantines don't grow unbounded. #135.
5757fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5758    let Some(dir) = config_path.parent() else {
5759        return;
5760    };
5761    let prefix = "config.json.corrupted.";
5762    let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5763        Ok(entries) => entries
5764            .filter_map(|e| e.ok())
5765            .map(|e| e.path())
5766            .filter(|p| {
5767                p.file_name()
5768                    .and_then(|n| n.to_str())
5769                    .is_some_and(|n| n.starts_with(prefix))
5770            })
5771            .collect(),
5772        Err(_) => return,
5773    };
5774    if quarantines.len() <= keep {
5775        return;
5776    }
5777    // Oldest first (by mtime; missing mtime sorts oldest so it's pruned first).
5778    quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5779    let remove = quarantines.len() - keep;
5780    for stale in quarantines.into_iter().take(remove) {
5781        if let Err(e) = std::fs::remove_file(&stale) {
5782            tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5783        }
5784    }
5785}
5786
5787/// Number of `config.json.bak[.N]` generations to retain (`.bak` + `N-1` numbered).
5788/// More generations = more recovery points if a fresher backup is itself bad. #135.
5789const BAK_GENERATIONS: usize = 3;
5790
5791/// The on-disk path of backup generation `gen` (0 == `config.json.bak`).
5792fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5793    if gen == 0 {
5794        config_path.with_extension("json.bak")
5795    } else {
5796        config_path.with_extension(format!("json.bak.{gen}"))
5797    }
5798}
5799
5800/// Shift the backup generations down before a fresh `.bak` is written:
5801/// `.bak.(N-2) -> .bak.(N-1)`, …, `.bak -> .bak.1`. The oldest is overwritten by
5802/// the shift; the caller then writes the new `.bak`. Walks the highest (oldest)
5803/// destination slot first so no rename clobbers a slot a later move still needs to
5804/// read. Best-effort. #135.
5805fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5806    for gen in (1..generations).rev() {
5807        let from = backup_path_for(config_path, gen - 1);
5808        let to = backup_path_for(config_path, gen);
5809        if from.exists() {
5810            if let Err(e) = std::fs::rename(&from, &to) {
5811                tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5812            }
5813        }
5814    }
5815}
5816
5817pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5818    crate::config_store::AtomicFileStore::new(path)
5819        .write_bytes_without_backup(content)
5820        .map_err(|error| match error {
5821            crate::config_store::ConfigStoreError::Io(error) => error,
5822            other => std::io::Error::other(other),
5823        })
5824}
5825
5826#[cfg(test)]
5827mod tests {
5828    use super::*;
5829    use std::ffi::OsString;
5830    use std::path::PathBuf;
5831    use std::sync::Mutex;
5832    use std::time::{SystemTime, UNIX_EPOCH};
5833
5834    #[test]
5835    fn tools_config_preserves_unknown_keys_across_roundtrip() {
5836        let input = serde_json::json!({
5837            "disabled": ["bash"],
5838            "plugin_runtime": {
5839                "timeout_ms": 5_000,
5840                "sandbox": true
5841            },
5842            "future_flag": "enabled"
5843        });
5844
5845        let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5846        assert_eq!(config.disabled, vec!["bash"]);
5847        assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5848        assert_eq!(config.extra["future_flag"], "enabled");
5849        assert_eq!(serde_json::to_value(config).unwrap(), input);
5850    }
5851
5852    #[test]
5853    fn tools_config_keeps_section_when_only_unknown_keys_present() {
5854        let input = serde_json::json!({
5855            "tool_extension": {
5856                "mode": "strict",
5857                "options": ["one", "two"]
5858            }
5859        });
5860        let config: Config = serde_json::from_value(serde_json::json!({
5861            "tools": input.clone()
5862        }))
5863        .unwrap();
5864
5865        assert!(config.tools.disabled.is_empty());
5866        assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5867        assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5868
5869        let temp_home = TempHome::new();
5870        config.save_to_dir(temp_home.path.clone()).unwrap();
5871        let persisted: Value =
5872            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5873                .unwrap();
5874        assert_eq!(persisted["tools"], input);
5875    }
5876
5877    #[test]
5878    fn skills_config_preserves_unknown_keys_across_roundtrip() {
5879        let input = serde_json::json!({
5880            "external_catalog": {
5881                "path": "/opt/bamboo/skills",
5882                "refresh": false
5883            },
5884            "schema_version": 2
5885        });
5886
5887        let config: Config = serde_json::from_value(serde_json::json!({
5888            "skills": input.clone()
5889        }))
5890        .unwrap();
5891        assert!(config.skills.disabled.is_empty());
5892        assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5893        assert_eq!(config.skills.extra["schema_version"], 2);
5894        assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5895
5896        let temp_home = TempHome::new();
5897        config.save_to_dir(temp_home.path.clone()).unwrap();
5898        let persisted: Value =
5899            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5900                .unwrap();
5901        assert_eq!(persisted["skills"], input);
5902    }
5903
5904    #[test]
5905    fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5906        let config: Config = serde_json::from_value(serde_json::json!({
5907            "hooks": {
5908                "image_fallback": {"enabled": true, "mode": "placeholder"}
5909            },
5910            "lifecycle_hooks": {
5911                "enabled": true,
5912                "PreToolUse": [{
5913                    "matcher": "bash|write_file",
5914                    "hooks": [{"type": "command", "command": "guard.sh"}]
5915                }],
5916                "SessionStart": [{
5917                    "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5918                }]
5919            }
5920        }))
5921        .expect("lifecycle hook config should deserialize");
5922
5923        assert!(config.lifecycle_hooks.enabled);
5924        assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5925        assert!(
5926            config.lifecycle_hooks.pre_tool_use[0].enabled,
5927            "legacy groups without an enabled flag remain active"
5928        );
5929        assert_eq!(
5930            config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5931            DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5932        );
5933        assert_eq!(
5934            config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5935            25
5936        );
5937
5938        let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5939        assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5940        assert_eq!(
5941            json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5942            "bash|write_file"
5943        );
5944        assert_eq!(
5945            json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5946            "command"
5947        );
5948        assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5949            .get("enabled")
5950            .is_none());
5951        assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5952            .get("timeout_ms")
5953            .is_none());
5954        assert!(json.get("hooks").is_some());
5955    }
5956
5957    #[test]
5958    fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5959        let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5960            "type": "script",
5961            "path": ".bamboo/hooks/check.js"
5962        }))
5963        .expect("script lifecycle hook should deserialize");
5964
5965        assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5966        assert!(matches!(
5967            handler,
5968            LifecycleHookHandler::Script {
5969                runner: LifecycleScriptRunner::Auto,
5970                ..
5971            }
5972        ));
5973        let json = serde_json::to_value(handler).expect("script hook should serialize");
5974        assert_eq!(json["type"], "script");
5975        assert!(json.get("timeout_ms").is_none());
5976        assert!(json.get("runner").is_none());
5977    }
5978
5979    #[test]
5980    fn script_runner_support_is_extension_aware() {
5981        assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5982        assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5983        assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5984        assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5985        assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5986        assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5987        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5988        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5989        assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5990        assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5991    }
5992
5993    #[test]
5994    fn script_runner_names_round_trip_through_config_json() {
5995        for (runner, name) in [
5996            (LifecycleScriptRunner::Auto, "auto"),
5997            (LifecycleScriptRunner::Node, "node"),
5998            (LifecycleScriptRunner::Bun, "bun"),
5999            (LifecycleScriptRunner::Python, "python"),
6000            (LifecycleScriptRunner::Bash, "bash"),
6001            (LifecycleScriptRunner::PowerShell, "powershell"),
6002            (LifecycleScriptRunner::Cmd, "cmd"),
6003        ] {
6004            let json = serde_json::to_value(runner).unwrap();
6005            assert_eq!(json, name);
6006            assert_eq!(
6007                serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
6008                runner
6009            );
6010        }
6011    }
6012
6013    #[test]
6014    fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
6015        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6016        assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
6017        let json = serde_json::to_value(&config).unwrap();
6018        assert!(json.get("lifecycle_hooks").is_none());
6019    }
6020
6021    #[test]
6022    fn stream_timeout_defaults_are_safe_and_back_compatible() {
6023        let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6024            .expect("legacy config without stream_timeout should load");
6025        let values = ConfigValues::from(root);
6026
6027        assert_eq!(
6028            values.stream_timeout,
6029            StreamTimeoutConfig {
6030                transport_idle_timeout_secs: 120,
6031                first_semantic_timeout_secs: 600,
6032                semantic_idle_timeout_secs: 600,
6033            }
6034        );
6035        values
6036            .stream_timeout
6037            .validate()
6038            .expect("defaults are valid");
6039    }
6040
6041    #[test]
6042    fn llm_retry_defaults_load_from_a_config_without_the_key_and_tolerate_slow_routes() {
6043        let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6044            .expect("legacy config without llm_retry should load");
6045        let values = ConfigValues::from(root);
6046        // Unset means "never configured": the environment-derived policy keeps
6047        // applying, which is what an existing deployment expects on upgrade.
6048        assert_eq!(values.llm_retry, None);
6049
6050        let defaults = LlmRetryConfig::default();
6051        assert_eq!(defaults.max_attempts, 8);
6052        assert_eq!(defaults.attempt_timeout_ms, 180_000);
6053        assert_eq!(defaults.total_budget_ms, 900_000);
6054        defaults.validate().expect("defaults are valid");
6055    }
6056
6057    #[test]
6058    fn llm_retry_rejects_out_of_range_or_inconsistent_policies() {
6059        let valid = LlmRetryConfig::default();
6060        assert!(valid.validate().is_ok());
6061        for broken in [
6062            LlmRetryConfig {
6063                max_attempts: 0,
6064                ..valid
6065            },
6066            LlmRetryConfig {
6067                max_attempts: MAX_LLM_RETRY_ATTEMPTS + 1,
6068                ..valid
6069            },
6070            LlmRetryConfig {
6071                attempt_timeout_ms: 999,
6072                ..valid
6073            },
6074            LlmRetryConfig {
6075                attempt_timeout_ms: MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS + 1,
6076                ..valid
6077            },
6078            LlmRetryConfig {
6079                total_budget_ms: 0,
6080                ..valid
6081            },
6082            LlmRetryConfig {
6083                total_budget_ms: MAX_LLM_RETRY_TOTAL_BUDGET_MS + 1,
6084                ..valid
6085            },
6086            // A base delay above the cap would never be honoured.
6087            LlmRetryConfig {
6088                base_delay_ms: 2_000,
6089                max_delay_ms: 1_000,
6090                ..valid
6091            },
6092        ] {
6093            assert!(broken.validate().is_err(), "{broken:?} must be rejected");
6094        }
6095    }
6096
6097    #[test]
6098    fn llm_retry_round_trips_through_its_section() {
6099        let policy = LlmRetryConfig {
6100            max_attempts: 12,
6101            attempt_timeout_ms: 300_000,
6102            total_budget_ms: 3_600_000,
6103            base_delay_ms: 250,
6104            max_delay_ms: 60_000,
6105        };
6106        let values = ConfigValues {
6107            llm_retry: Some(policy),
6108            ..ConfigValues::default()
6109        };
6110        let json = serde_json::to_value(&values).expect("serialize");
6111        assert_eq!(json["llm_retry"]["max_attempts"], 12);
6112        let round_tripped: ConfigValues = serde_json::from_value(json).expect("deserialize");
6113        assert_eq!(round_tripped.llm_retry, Some(policy));
6114    }
6115
6116    #[test]
6117    fn stream_timeout_round_trips_through_persistence_dto() {
6118        let values = ConfigValues {
6119            stream_timeout: StreamTimeoutConfig {
6120                transport_idle_timeout_secs: 45,
6121                first_semantic_timeout_secs: 900,
6122                semantic_idle_timeout_secs: 300,
6123            },
6124            ..ConfigValues::default()
6125        };
6126
6127        let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
6128        assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
6129        assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
6130        assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
6131
6132        let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
6133        assert_eq!(
6134            ConfigValues::from(root).stream_timeout,
6135            StreamTimeoutConfig {
6136                transport_idle_timeout_secs: 45,
6137                first_semantic_timeout_secs: 900,
6138                semantic_idle_timeout_secs: 300,
6139            }
6140        );
6141    }
6142
6143    #[test]
6144    fn context_management_defaults_to_summary_and_is_omitted() {
6145        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6146        assert_eq!(
6147            config.context_management.strategy,
6148            ContextManagementStrategy::Summary
6149        );
6150        assert_eq!(
6151            config
6152                .context_management
6153                .retrieval_window
6154                .min_recent_user_turns,
6155            3
6156        );
6157        assert_eq!(
6158            config.context_management.retrieval_target_usage_percent(),
6159            25
6160        );
6161        let json = serde_json::to_value(&config).unwrap();
6162        assert!(json.get("context_management").is_none());
6163    }
6164
6165    #[test]
6166    fn retrieval_window_context_management_round_trips() {
6167        let json = serde_json::json!({
6168            "context_management": {
6169                "strategy": "retrieval_window",
6170                "retrieval_window": {
6171                    "min_recent_user_turns": 4,
6172                    "trigger_usage_ratio": 0.82,
6173                    "target_usage_ratio": 0.61,
6174                    "history_tool_required": true,
6175                    "fallback_strategy": "summary"
6176                }
6177            }
6178        });
6179        let root: ConfigRoot = serde_json::from_value(json).unwrap();
6180        let values = ConfigValues::from(root);
6181        assert_eq!(
6182            values.context_management.strategy,
6183            ContextManagementStrategy::RetrievalWindow
6184        );
6185        assert_eq!(
6186            values.context_management.retrieval_window.fallback_strategy,
6187            ContextManagementFallbackStrategy::Summary
6188        );
6189
6190        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6191        assert_eq!(
6192            persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
6193            4
6194        );
6195        assert_eq!(
6196            persisted["context_management"]["strategy"],
6197            "retrieval_window"
6198        );
6199    }
6200
6201    #[test]
6202    fn message_summary_context_management_round_trips_without_changing_defaults() {
6203        let root: ConfigRoot = serde_json::from_value(serde_json::json!({
6204            "context_management": {
6205                "strategy": "message_summary"
6206            }
6207        }))
6208        .unwrap();
6209        let values = ConfigValues::from(root);
6210        assert_eq!(
6211            values.context_management.strategy,
6212            ContextManagementStrategy::MessageSummary
6213        );
6214        assert_eq!(
6215            values.context_management.retrieval_window,
6216            RetrievalWindowContextConfig::default()
6217        );
6218
6219        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6220        assert_eq!(
6221            persisted["context_management"]["strategy"],
6222            "message_summary"
6223        );
6224        let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
6225        assert_eq!(
6226            ConfigValues::from(reloaded).context_management.strategy,
6227            ContextManagementStrategy::MessageSummary
6228        );
6229        assert_eq!(
6230            ContextManagementConfig::default().strategy,
6231            ContextManagementStrategy::Summary
6232        );
6233    }
6234
6235    #[test]
6236    fn context_management_uses_one_explicit_policy_for_every_strategy() {
6237        for strategy in ["summary", "message_summary", "retrieval_window"] {
6238            let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6239                "strategy": strategy,
6240                "trigger_usage_ratio": 0.72,
6241                "target_usage_ratio": 0.20,
6242                "retrieval_window": {"trigger_usage_ratio": 0.9, "target_usage_ratio": 0.6}
6243            }))
6244            .unwrap();
6245            let budget =
6246                policy.apply_compression_policy(bamboo_domain::TokenBudget::for_model(200_000));
6247            assert_eq!(budget.compression_trigger_percent, 72);
6248            assert_eq!(budget.compression_target_percent, 20);
6249            let saved = serde_json::to_value(&policy).unwrap();
6250            let restored: ContextManagementConfig = serde_json::from_value(saved).unwrap();
6251            assert_eq!(restored, policy);
6252        }
6253    }
6254
6255    #[test]
6256    fn context_management_migrates_stricter_legacy_ratios_without_preserving_old_sixty_percent_target(
6257    ) {
6258        for strategy in ["summary", "message_summary", "retrieval_window"] {
6259            for (trigger, target, expected_trigger, expected_target) in
6260                [(0.80, 0.60, 0.80, 0.25), (0.65, 0.15, 0.65, 0.15)]
6261            {
6262                let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6263                    "strategy": strategy,
6264                    "retrieval_window": {"trigger_usage_ratio": trigger, "target_usage_ratio": target}
6265                })).unwrap();
6266                assert_eq!(policy.trigger_usage_ratio, expected_trigger);
6267                assert_eq!(policy.target_usage_ratio, expected_target);
6268            }
6269        }
6270    }
6271
6272    #[test]
6273    fn context_management_rejects_unrepresentable_shared_range() {
6274        for (trigger, target) in [(0.80, 0.80), (1.01, 0.25), (0.015, 0.01), (0.80, 0.005)] {
6275            let value =
6276                serde_json::json!({"trigger_usage_ratio": trigger, "target_usage_ratio": target});
6277            assert!(serde_json::from_value::<ContextManagementConfig>(value).is_err());
6278        }
6279    }
6280
6281    #[test]
6282    fn retrieval_window_context_management_rejects_unsafe_policy() {
6283        for invalid in [
6284            serde_json::json!({
6285                "strategy": "retrieval_window",
6286                "retrieval_window": {"min_recent_user_turns": 0}
6287            }),
6288            serde_json::json!({
6289                "strategy": "retrieval_window",
6290                "retrieval_window": {
6291                    "target_usage_ratio": 0.8,
6292                    "trigger_usage_ratio": 0.8
6293                }
6294            }),
6295            serde_json::json!({
6296                "strategy": "retrieval_window",
6297                "retrieval_window": {
6298                    "target_usage_ratio": 0.005,
6299                    "trigger_usage_ratio": 0.006
6300                }
6301            }),
6302            serde_json::json!({
6303                "strategy": "retrieval_window",
6304                "retrieval_window": {"history_tool_required": false}
6305            }),
6306        ] {
6307            serde_json::from_value::<ContextManagementConfig>(invalid)
6308                .expect_err("unsafe retrieval-window policy must fail closed");
6309        }
6310    }
6311
6312    #[test]
6313    fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6314        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6315            "provider_type": "openai",
6316            "enabled": true
6317        }))
6318        .unwrap();
6319        let mut config = Config::default();
6320        config.values.provider = "gemini".to_string();
6321        config
6322            .provider_instances
6323            .insert("work".to_string(), instance);
6324        config.default_provider_instance = Some("work".to_string());
6325        config.providers_mut().openai = Some(OpenAIConfig::default());
6326
6327        let json = serde_json::to_value(&config).unwrap();
6328        assert_eq!(json["provider"], "gemini");
6329        assert!(json["providers"]["openai"].is_object());
6330        assert_eq!(json["default_provider_instance"], "work");
6331
6332        let round_trip: Config = serde_json::from_value(json).unwrap();
6333        assert_eq!(round_trip.provider, "gemini");
6334        assert_eq!(
6335            round_trip.default_provider_instance.as_deref(),
6336            Some("work")
6337        );
6338        assert!(round_trip.provider_instances.contains_key("work"));
6339        assert!(round_trip.providers().openai.is_some());
6340    }
6341
6342    #[test]
6343    fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6344        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6345        let dir = tempfile::tempdir().unwrap();
6346        let mut config = Config::default();
6347        config.values.provider = "anthropic".to_string();
6348        config.provider_instances.insert(
6349            "work".to_string(),
6350            serde_json::from_value(serde_json::json!({
6351                "provider_type": "openai",
6352                "model": "gpt-instance",
6353                "enabled": true,
6354                "future_instance_metadata": "preserved"
6355            }))
6356            .unwrap(),
6357        );
6358        config.default_provider_instance = Some("work".to_string());
6359        config.features.provider_model_ref = true;
6360        config.providers_mut().openai = Some(OpenAIConfig::default());
6361        config.providers_mut().anthropic = Some(AnthropicConfig::default());
6362        config
6363            .providers_mut()
6364            .extra
6365            .insert("provider".to_string(), serde_json::json!("anthropic"));
6366        config.providers_mut().extra.insert(
6367            "future_provider".to_string(),
6368            serde_json::json!({"kept": true}),
6369        );
6370
6371        let (root_bytes, provider_bytes) =
6372            config.prepare_provider_transaction_documents(&[]).unwrap();
6373        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6374        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6375        assert!(root.get("provider").is_none());
6376        assert!(root.get("providers").is_none());
6377        assert_eq!(root["default_provider_instance"], "work");
6378        assert_eq!(
6379            root["provider_instances"]["work"]["future_instance_metadata"],
6380            "preserved"
6381        );
6382        assert!(providers.get("provider").is_none());
6383        for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6384            assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6385        }
6386        assert_eq!(providers["future_provider"]["kept"], true);
6387
6388        config.save_to_dir(dir.path().to_path_buf()).unwrap();
6389        let saved_root: Value =
6390            serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6391                .unwrap();
6392        let saved_providers: Value =
6393            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6394                .unwrap();
6395        assert!(saved_root.get("provider").is_none());
6396        assert!(saved_root.get("providers").is_none());
6397        assert_eq!(saved_root["default_provider_instance"], "work");
6398        assert!(saved_providers.get("openai").is_none());
6399        assert!(saved_providers.get("anthropic").is_none());
6400        assert!(saved_providers.get("provider").is_none());
6401        assert_eq!(saved_providers["future_provider"]["kept"], true);
6402
6403        config.providers_mut().gemini = Some(GeminiConfig::default());
6404        config.save_providers_to_dir(dir.path()).unwrap();
6405        let provider_only: Value =
6406            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6407                .unwrap();
6408        assert!(provider_only.get("gemini").is_none());
6409        assert!(provider_only.get("provider").is_none());
6410        assert_eq!(provider_only["future_provider"]["kept"], true);
6411    }
6412
6413    #[test]
6414    fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6415        let mut config = Config::default();
6416        config.values.provider = "openai".to_string();
6417        config.providers_mut().openai = Some(OpenAIConfig {
6418            model: Some("gpt-legacy".to_string()),
6419            ..OpenAIConfig::default()
6420        });
6421        config.provider_instances.insert(
6422            "work".to_string(),
6423            serde_json::from_value(serde_json::json!({
6424                "provider_type": "copilot",
6425                "enabled": true
6426            }))
6427            .unwrap(),
6428        );
6429        config.default_provider_instance = Some("openai".to_string());
6430
6431        let (root_bytes, provider_bytes) =
6432            config.prepare_provider_transaction_documents(&[]).unwrap();
6433        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6434        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6435        assert_eq!(root["provider"], "openai");
6436        assert_eq!(root["default_provider_instance"], "openai");
6437        assert!(root["provider_instances"]["work"].is_object());
6438        assert_eq!(providers["openai"]["model"], "gpt-legacy");
6439    }
6440
6441    #[test]
6442    fn persistence_keeps_legacy_provider_without_instance_default() {
6443        let values = ConfigValues {
6444            provider: "gemini".to_string(),
6445            ..ConfigValues::default()
6446        };
6447
6448        let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6449        assert_eq!(json["provider"], "gemini");
6450    }
6451
6452    #[test]
6453    fn stream_timeout_rejects_zero_and_unbounded_values() {
6454        for (field, invalid) in [
6455            ("transport_idle_timeout_secs", 0),
6456            ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6457            ("semantic_idle_timeout_secs", 0),
6458        ] {
6459            let mut timeout = serde_json::json!({});
6460            timeout[field] = serde_json::json!(invalid);
6461            let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6462                .expect_err("invalid timeout must be rejected");
6463            assert!(error.to_string().contains("stream timeout must be between"));
6464        }
6465    }
6466
6467    struct EnvVarGuard {
6468        key: &'static str,
6469        previous: Option<OsString>,
6470    }
6471
6472    impl EnvVarGuard {
6473        fn set(key: &'static str, value: &str) -> Self {
6474            let previous = std::env::var_os(key);
6475            std::env::set_var(key, value);
6476            Self { key, previous }
6477        }
6478
6479        fn unset(key: &'static str) -> Self {
6480            let previous = std::env::var_os(key);
6481            std::env::remove_var(key);
6482            Self { key, previous }
6483        }
6484    }
6485
6486    impl Drop for EnvVarGuard {
6487        fn drop(&mut self) {
6488            match &self.previous {
6489                Some(value) => std::env::set_var(self.key, value),
6490                None => std::env::remove_var(self.key),
6491            }
6492        }
6493    }
6494
6495    #[test]
6496    fn run_budget_config_merge_is_tighten_only_per_field() {
6497        let config_default = RunBudgetConfig {
6498            max_total_tokens: Some(100_000),
6499            max_tool_calls: Some(500),
6500            max_subagents: Some(10),
6501            max_rounds: Some(200),
6502        };
6503
6504        // No override at all: config default passes through unchanged.
6505        assert_eq!(
6506            config_default.merged_with_override(None),
6507            config_default,
6508            "no override falls back to the config default entirely"
6509        );
6510
6511        // Override TIGHTENS exactly one field; the others keep the config
6512        // default (per-field, not all-or-nothing).
6513        let tighten_one = RunBudgetConfig {
6514            max_total_tokens: Some(5_000),
6515            max_tool_calls: None,
6516            max_subagents: None,
6517            max_rounds: None,
6518        };
6519        let merged = config_default.merged_with_override(Some(&tighten_one));
6520        assert_eq!(merged.max_total_tokens, Some(5_000));
6521        assert_eq!(merged.max_tool_calls, Some(500));
6522        assert_eq!(merged.max_subagents, Some(10));
6523        assert_eq!(merged.max_rounds, Some(200));
6524
6525        // A LOOSER override is clamped to the config default: a client can
6526        // never raise the operator's ceiling (PR #539 review, finding #3).
6527        let loosen_attempt = RunBudgetConfig {
6528            max_total_tokens: Some(999_999_999),
6529            max_tool_calls: Some(10_000),
6530            max_subagents: Some(1_000),
6531            max_rounds: Some(100_000),
6532        };
6533        assert_eq!(
6534            config_default.merged_with_override(Some(&loosen_attempt)),
6535            config_default,
6536            "looser per-request values must be clamped to the config ceiling"
6537        );
6538
6539        // Nor can it REMOVE a configured ceiling by omitting the field: an
6540        // absent override field keeps the config default, it does not mean
6541        // unlimited.
6542        let empty_override = RunBudgetConfig::default();
6543        assert_eq!(
6544            config_default.merged_with_override(Some(&empty_override)),
6545            config_default,
6546            "an all-absent override body keeps every configured ceiling"
6547        );
6548
6549        // An unlimited config default CAN be tightened by the request (the
6550        // request is the only ceiling then), and stays unlimited on fields the
6551        // request does not set.
6552        let unlimited_default = RunBudgetConfig::default();
6553        let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6554        assert_eq!(merged.max_total_tokens, Some(5_000));
6555        assert_eq!(merged.max_tool_calls, None);
6556        assert_eq!(merged.max_subagents, None);
6557        assert_eq!(merged.max_rounds, None);
6558
6559        // The round cap tightens the same way: a request can cap rounds below
6560        // an unlimited config default but never raise a configured ceiling.
6561        let tighten_rounds = RunBudgetConfig {
6562            max_total_tokens: None,
6563            max_tool_calls: None,
6564            max_subagents: None,
6565            max_rounds: Some(20),
6566        };
6567        let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6568        assert_eq!(merged.max_rounds, Some(20));
6569        let merged = config_default.merged_with_override(Some(&tighten_rounds));
6570        assert_eq!(
6571            merged.max_rounds,
6572            Some(20),
6573            "a stricter per-request round cap wins over the config default"
6574        );
6575    }
6576
6577    #[test]
6578    fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6579        assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6580        assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6581        assert_eq!(RunBudgetConfig::default().max_subagents, None);
6582        assert_eq!(RunBudgetConfig::default().max_rounds, None);
6583
6584        let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6585        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6586        assert_eq!(cfg.max_total_tokens, Some(250_000));
6587        assert_eq!(
6588            cfg.max_tool_calls, None,
6589            "absent field defaults to unlimited"
6590        );
6591        assert_eq!(
6592            cfg.max_rounds, None,
6593            "absent round cap defaults to unlimited"
6594        );
6595
6596        assert_eq!(cfg.max_subagents, Some(3));
6597
6598        // The round cap round-trips like the other fields.
6599        let json = r#"{ "max_rounds": 250 }"#;
6600        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6601        assert_eq!(cfg.max_rounds, Some(250));
6602        let round_tripped: RunBudgetConfig =
6603            serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6604        assert_eq!(round_tripped.max_rounds, Some(250));
6605        assert_eq!(round_tripped.max_total_tokens, None);
6606
6607        // Absent fields are omitted on serialize (skip_serializing_if), so an
6608        // all-default config round-trips to `{}` rather than four explicit
6609        // nulls.
6610        let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6611        assert_eq!(empty, "{}");
6612    }
6613
6614    #[test]
6615    fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6616        let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6617        assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6618        assert!(!serde_json::to_string(&old)
6619            .unwrap()
6620            .contains("max_spawn_depth"));
6621        for depth in [0, 1, 8] {
6622            let config: SubagentsConfig =
6623                serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6624            assert_eq!(config.effective_max_spawn_depth(), depth);
6625            assert_eq!(
6626                serde_json::to_value(config).unwrap()["max_spawn_depth"],
6627                depth
6628            );
6629        }
6630        for invalid in [
6631            serde_json::json!(-1),
6632            serde_json::json!(1.5),
6633            serde_json::json!("4"),
6634        ] {
6635            assert!(serde_json::from_value::<SubagentsConfig>(
6636                serde_json::json!({"max_spawn_depth": invalid}),
6637            )
6638            .is_err());
6639        }
6640    }
6641
6642    #[test]
6643    fn subagents_config_without_remote_placements_deserializes_empty() {
6644        // An OLD config (predating P1.5) has no `remote_placements` key — it must
6645        // still deserialize, with an empty placement list (default = local path).
6646        let json = r#"{ "max_concurrent": 4 }"#;
6647        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6648        assert_eq!(cfg.max_concurrent, Some(4));
6649        assert!(cfg.remote_placements.is_empty());
6650        // And an empty placement list is omitted on re-serialize (skip_if empty).
6651        let back = serde_json::to_string(&cfg).unwrap();
6652        assert!(
6653            !back.contains("remote_placements"),
6654            "empty vec is skipped: {back}"
6655        );
6656    }
6657
6658    #[test]
6659    fn remote_actor_placement_round_trips() {
6660        let json = r#"{
6661            "remote_placements": [
6662                {
6663                    "role": "explorer",
6664                    "endpoint": "wss://gpu-host:8443",
6665                    "token_env": "WORKER_TOKEN",
6666                    "ca_cert_file": "/etc/bamboo/worker.pem"
6667                },
6668                { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6669            ]
6670        }"#;
6671        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6672        assert_eq!(cfg.remote_placements.len(), 2);
6673        let p0 = &cfg.remote_placements[0];
6674        assert_eq!(p0.role, "explorer");
6675        assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6676        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6677        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6678        // Optional fields default to None and are skipped on serialize.
6679        let p1 = &cfg.remote_placements[1];
6680        assert_eq!(p1.role, "writer");
6681        assert!(p1.token_env.is_none());
6682        assert!(p1.ca_cert_file.is_none());
6683
6684        let back = serde_json::to_string(&cfg).unwrap();
6685        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6686        assert_eq!(cfg, reparsed, "round-trip is stable");
6687        assert!(!back.contains("\"token_env\":null"));
6688        assert!(!back.contains("\"ca_cert_file\":null"));
6689    }
6690
6691    #[test]
6692    fn subagents_config_without_schedulable_placements_deserializes_empty() {
6693        // An OLD config (predating P2b) has no `schedulable_placements` key — it
6694        // must still deserialize, with an empty list (default = local path).
6695        let json = r#"{ "max_concurrent": 4 }"#;
6696        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6697        assert!(cfg.schedulable_placements.is_empty());
6698        // An empty list is omitted on re-serialize (skip_if empty).
6699        let back = serde_json::to_string(&cfg).unwrap();
6700        assert!(
6701            !back.contains("schedulable_placements"),
6702            "empty vec is skipped: {back}"
6703        );
6704    }
6705
6706    #[test]
6707    fn schedulable_placement_round_trips() {
6708        let json = r#"{
6709            "schedulable_placements": [
6710                {
6711                    "role": "explorer",
6712                    "pool": "gpu-pool",
6713                    "registry_url": "https://control-plane:9562",
6714                    "token_env": "WORKER_TOKEN",
6715                    "ca_cert_file": "/etc/bamboo/worker.pem"
6716                },
6717                { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6718            ]
6719        }"#;
6720        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6721        assert_eq!(cfg.schedulable_placements.len(), 2);
6722        let p0 = &cfg.schedulable_placements[0];
6723        assert_eq!(p0.role, "explorer");
6724        assert_eq!(p0.pool, "gpu-pool");
6725        assert_eq!(p0.registry_url, "https://control-plane:9562");
6726        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6727        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6728        // Optional fields default to None and are skipped on serialize.
6729        let p1 = &cfg.schedulable_placements[1];
6730        assert_eq!(p1.role, "writer");
6731        assert_eq!(p1.pool, "cpu-pool");
6732        assert!(p1.token_env.is_none());
6733        assert!(p1.ca_cert_file.is_none());
6734
6735        let back = serde_json::to_string(&cfg).unwrap();
6736        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6737        assert_eq!(cfg, reparsed, "round-trip is stable");
6738        assert!(!back.contains("\"token_env\":null"));
6739        assert!(!back.contains("\"ca_cert_file\":null"));
6740    }
6741
6742    #[test]
6743    fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6744        // An OLD config (predating #54's wiring) has no `mcp_role_allowlist`
6745        // key — it must still deserialize, with an empty list (default =
6746        // every role unrestricted, identical to pre-#54 behavior).
6747        let json = r#"{ "max_concurrent": 4 }"#;
6748        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6749        assert!(cfg.mcp_role_allowlist.is_empty());
6750        // An empty list is omitted on re-serialize (skip_if empty).
6751        let back = serde_json::to_string(&cfg).unwrap();
6752        assert!(
6753            !back.contains("mcp_role_allowlist"),
6754            "empty vec is skipped: {back}"
6755        );
6756    }
6757
6758    #[test]
6759    fn mcp_role_allowlist_entry_round_trips() {
6760        let json = r#"{
6761            "mcp_role_allowlist": [
6762                { "role": "researcher", "tools": ["fetch_url"] },
6763                { "role": "sandboxed", "tools": [] }
6764            ]
6765        }"#;
6766        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6767        assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6768        assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6769        assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6770        // An empty `tools` list is an explicit lockout, distinct from the role
6771        // being absent — it must round-trip as an empty (not omitted) list.
6772        assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6773        assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6774
6775        let back = serde_json::to_string(&cfg).unwrap();
6776        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6777        assert_eq!(cfg, reparsed, "round-trip is stable");
6778    }
6779
6780    #[test]
6781    fn server_config_without_tls_field_deserializes_back_compat() {
6782        // An old config.json `server` section with no `tls` key must still
6783        // deserialize, leaving `tls` as None (zero behavior change on upgrade).
6784        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6785            "port": 9562,
6786            "bind": "127.0.0.1"
6787        }))
6788        .expect("legacy server config without tls should deserialize");
6789
6790        assert_eq!(server.tls, None);
6791        assert_eq!(server.port, 9562);
6792        assert_eq!(server.bind, "127.0.0.1");
6793    }
6794
6795    #[test]
6796    fn server_config_omits_tls_when_none() {
6797        // `skip_serializing_if = "Option::is_none"` keeps the on-disk shape
6798        // identical to before for the common (no-TLS) case.
6799        let server = ServerConfig::default();
6800        let value = serde_json::to_value(&server).expect("server config should serialize");
6801        let obj = value
6802            .as_object()
6803            .expect("server config serializes to object");
6804        assert!(
6805            !obj.contains_key("tls"),
6806            "tls must be omitted when None, got: {value}"
6807        );
6808    }
6809
6810    #[test]
6811    fn server_config_with_tls_roundtrips() {
6812        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6813            "port": 9562,
6814            "bind": "0.0.0.0",
6815            "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6816        }))
6817        .expect("server config with tls should deserialize");
6818
6819        let tls = server.tls.clone().expect("tls should be Some");
6820        assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6821        assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6822
6823        // Round-trips: tls survives a serialize → deserialize cycle.
6824        let value = serde_json::to_value(&server).expect("serialize");
6825        assert!(value.as_object().unwrap().contains_key("tls"));
6826        let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6827        assert_eq!(back.tls, server.tls);
6828    }
6829
6830    #[test]
6831    fn access_control_without_devices_field_deserializes_back_compat() {
6832        // An old config.json `access_control` with no `devices` key must still
6833        // deserialize, leaving `devices` empty (root-password-only mode).
6834        let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6835            "password_enabled": true,
6836            "password_hash": "deadbeef",
6837            "password_salt": "01020304",
6838        }))
6839        .expect("legacy access_control without devices should deserialize");
6840
6841        assert!(access.devices.is_empty());
6842        assert!(access.password_enabled);
6843    }
6844
6845    #[test]
6846    fn access_control_omits_devices_when_empty() {
6847        // `skip_serializing_if = "Vec::is_empty"` keeps the on-disk shape
6848        // identical for instances that never paired a device.
6849        let access = AccessControlConfig {
6850            password_enabled: true,
6851            repair_required: false,
6852            password_hash: Some("deadbeef".to_string()),
6853            password_salt: Some("01020304".to_string()),
6854            password_credential_ref: None,
6855            password_configured: false,
6856            updated_at: None,
6857            devices: Vec::new(),
6858        };
6859        let value = serde_json::to_value(&access).expect("serialize");
6860        let obj = value.as_object().expect("object");
6861        assert!(
6862            !obj.contains_key("devices"),
6863            "devices must be omitted when empty, got: {value}"
6864        );
6865    }
6866
6867    #[test]
6868    fn access_control_with_devices_roundtrips() {
6869        let device = DeviceCredential {
6870            device_id: "bamboo_0123456789ab".to_string(),
6871            label: "iPhone 15".to_string(),
6872            token_hash: "abcd".to_string(),
6873            token_salt: "ef01".to_string(),
6874            token_credential_ref: None,
6875            token_configured: false,
6876            created_at: "2026-06-23T00:00:00Z".to_string(),
6877            last_used_at: None,
6878            revoked: false,
6879        };
6880        let access = AccessControlConfig {
6881            password_enabled: true,
6882            repair_required: false,
6883            password_hash: Some("deadbeef".to_string()),
6884            password_salt: Some("01020304".to_string()),
6885            password_credential_ref: None,
6886            password_configured: false,
6887            updated_at: None,
6888            devices: vec![device.clone()],
6889        };
6890        let value = serde_json::to_value(&access).expect("serialize");
6891        assert!(value.as_object().unwrap().contains_key("devices"));
6892        assert!(value["devices"][0].get("token_hash").is_none());
6893        assert!(value["devices"][0].get("token_salt").is_none());
6894        let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6895        assert_eq!(back.devices[0].device_id, device.device_id);
6896        assert!(back.devices[0].token_hash.is_empty());
6897        assert!(back.devices[0].token_salt.is_empty());
6898    }
6899
6900    #[test]
6901    fn reasoning_effort_for_key_resolves_instance_id() {
6902        // Multi-instance mode: the routing key is an instance id and the effort
6903        // lives under provider_instances[<id>] — previously this fell through to
6904        // None because the resolver only matched literal provider types.
6905        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6906            "provider_type": "copilot",
6907            "reasoning_effort": "high",
6908        }))
6909        .expect("instance config should deserialize");
6910
6911        let mut config = Config::create_default();
6912        config
6913            .provider_instances
6914            .insert("copilot-work".to_string(), instance);
6915
6916        assert_eq!(
6917            config.reasoning_effort_for_key("copilot-work"),
6918            Some(ReasoningEffort::High),
6919        );
6920    }
6921
6922    #[test]
6923    fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6924        // Legacy mode: the `bodhi` provider previously had no match arm.
6925        let mut config = Config::create_default();
6926        config.providers.bodhi = Some(
6927            serde_json::from_value(serde_json::json!({
6928                "reasoning_effort": "xhigh",
6929            }))
6930            .expect("bodhi config should deserialize"),
6931        );
6932
6933        assert_eq!(
6934            config.reasoning_effort_for_key("bodhi"),
6935            Some(ReasoningEffort::Xhigh),
6936        );
6937    }
6938
6939    #[test]
6940    fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6941        let mut config = Config::create_default();
6942        config.providers.openai = Some(
6943            serde_json::from_value(serde_json::json!({
6944                "api_key": "sk-test",
6945                "reasoning_effort": "low",
6946            }))
6947            .expect("openai config should deserialize"),
6948        );
6949
6950        assert_eq!(
6951            config.reasoning_effort_for_key("openai"),
6952            Some(ReasoningEffort::Low),
6953        );
6954    }
6955
6956    #[test]
6957    fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6958        let config = Config::create_default();
6959        assert_eq!(config.reasoning_effort_for_key("nope"), None);
6960        assert_eq!(config.reasoning_effort_for_key("   "), None);
6961    }
6962
6963    struct TempHome {
6964        path: PathBuf,
6965    }
6966
6967    impl TempHome {
6968        fn new() -> Self {
6969            // `pid + nanos` alone is NOT collision-free (issue #486): every
6970            // test in this binary shares the pid, and two tests started
6971            // concurrently by the multi-threaded harness can observe the
6972            // same `SystemTime` nanos tick. Two `TempHome`s colliding on one
6973            // path means they share a directory — and the first test's
6974            // `Drop` (`remove_dir_all`) then yanks the directory out from
6975            // under the other test's in-flight `save_to_dir`, whose
6976            // tmp-file+rename dance fails with ENOENT ("Failed to write
6977            // config file ... os error 2" — `save_rotates_backup_generations`'s
6978            // exact one-off CI failure mode). A per-process atomic counter
6979            // in the name makes each instance unique unconditionally.
6980            static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6981                std::sync::atomic::AtomicU64::new(0);
6982            let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6983            let nanos = SystemTime::now()
6984                .duration_since(UNIX_EPOCH)
6985                .expect("clock should be after unix epoch")
6986                .as_nanos();
6987            let path = std::env::temp_dir().join(format!(
6988                "chat-core-config-test-{}-{}-{}",
6989                std::process::id(),
6990                nanos,
6991                unique
6992            ));
6993            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6994            Self { path }
6995        }
6996
6997        fn set_config_json(&self, content: &str) {
6998            // Treat `path` as the Bamboo data dir and write `config.json` into it.
6999            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
7000            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
7001            std::fs::write(self.path.join("config.json"), content)
7002                .expect("failed to write config.json");
7003        }
7004    }
7005
7006    impl Drop for TempHome {
7007        fn drop(&mut self) {
7008            let _ = std::fs::remove_dir_all(&self.path);
7009        }
7010    }
7011
7012    // Delegate to the single crate-wide test lock so env-mutating tests across
7013    // `config`, `encryption`, and `paths` serialize against one another (they
7014    // all mutate the same process-global env / static caches).
7015    fn env_lock() -> &'static Mutex<()> {
7016        crate::test_support::env_cache_lock()
7017    }
7018
7019    /// Acquire the environment lock, recovering from poison if a previous test failed
7020    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
7021        env_lock().lock().unwrap_or_else(|poisoned| {
7022            // Lock was poisoned by a previous test failure - recover it
7023            poisoned.into_inner()
7024        })
7025    }
7026
7027    #[test]
7028    fn parse_bool_env_true_values() {
7029        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
7030            assert!(parse_bool_env(value), "value {value:?} should be true");
7031        }
7032    }
7033
7034    #[test]
7035    fn parse_bool_env_false_values() {
7036        for value in ["0", "false", "no", "off", "", "  "] {
7037            assert!(!parse_bool_env(value), "value {value:?} should be false");
7038        }
7039    }
7040
7041    #[test]
7042    fn config_new_ignores_http_proxy_env_vars() {
7043        let _lock = env_lock_acquire();
7044        let temp_home = TempHome::new();
7045        temp_home.set_config_json(
7046            r#"{
7047  "http_proxy": "",
7048  "https_proxy": ""
7049}"#,
7050        );
7051
7052        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
7053        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
7054
7055        let config = Config::from_data_dir(Some(temp_home.path.clone()));
7056
7057        assert!(
7058            config.http_proxy.is_empty(),
7059            "config should ignore HTTP_PROXY env var"
7060        );
7061        assert!(
7062            config.https_proxy.is_empty(),
7063            "config should ignore HTTPS_PROXY env var"
7064        );
7065    }
7066
7067    #[test]
7068    fn config_new_loads_config_when_proxy_fields_omitted() {
7069        let _lock = env_lock_acquire();
7070        let temp_home = TempHome::new();
7071        temp_home.set_config_json(
7072            r#"{
7073  "provider": "openai",
7074  "providers": {
7075    "openai": {
7076      "api_key": "sk-test",
7077      "model": "gpt-4o"
7078    }
7079  }
7080}"#,
7081        );
7082
7083        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
7084        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
7085
7086        let config = Config::from_data_dir(Some(temp_home.path.clone()));
7087
7088        assert_eq!(
7089            config
7090                .providers
7091                .openai
7092                .as_ref()
7093                .and_then(|c| c.model.as_deref()),
7094            Some("gpt-4o"),
7095            "config should load provider model from config file even when proxy fields are omitted"
7096        );
7097        assert!(config.http_proxy.is_empty());
7098        assert!(config.https_proxy.is_empty());
7099    }
7100
7101    #[test]
7102    fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
7103        let _lock = crate::test_support::env_cache_lock_acquire();
7104        let mut config = Config::default();
7105        config.env_vars.extend([
7106            EnvVarEntry {
7107                name: "SECRET_TOKEN".to_string(),
7108                value: "top-secret".to_string(),
7109                secret: true,
7110                value_encrypted: None,
7111                credential_ref: None,
7112                configured: true,
7113                description: Some("Service token".to_string()),
7114            },
7115            EnvVarEntry {
7116                name: "API_BASE".to_string(),
7117                value: "https://internal.example".to_string(),
7118                secret: false,
7119                value_encrypted: None,
7120                credential_ref: None,
7121                configured: true,
7122                description: Some("Internal API base".to_string()),
7123            },
7124        ]);
7125
7126        config.publish_env_vars();
7127
7128        let injected = Config::current_env_vars();
7129        assert_eq!(
7130            injected.get("SECRET_TOKEN").map(String::as_str),
7131            Some("top-secret")
7132        );
7133        assert_eq!(
7134            injected.get("API_BASE").map(String::as_str),
7135            Some("https://internal.example")
7136        );
7137
7138        let prompt_safe = Config::current_prompt_safe_env_vars();
7139        assert_eq!(prompt_safe.len(), 2);
7140        assert!(prompt_safe.iter().any(|entry| {
7141            entry.name == "SECRET_TOKEN"
7142                && entry.secret
7143                && entry.description.as_deref() == Some("Service token")
7144        }));
7145        assert!(prompt_safe.iter().any(|entry| {
7146            entry.name == "API_BASE"
7147                && !entry.secret
7148                && entry.description.as_deref() == Some("Internal API base")
7149        }));
7150        assert!(!prompt_safe
7151            .iter()
7152            .any(|entry| entry.name.contains("top-secret")));
7153        assert!(!prompt_safe.iter().any(|entry| {
7154            entry
7155                .description
7156                .as_deref()
7157                .is_some_and(|value| value.contains("https://internal.example"))
7158        }));
7159    }
7160
7161    #[test]
7162    fn from_data_dir_without_publish_does_not_clobber_global_cache() {
7163        let _lock = crate::test_support::env_cache_lock_acquire();
7164
7165        // Seed the global cache with a marker "owned" by the live config.
7166        let mut live = Config::default();
7167        live.env_vars.extend([EnvVarEntry {
7168            name: "BAMBOO_CACHE_OWNER_40".to_string(),
7169            value: "live".to_string(),
7170            secret: false,
7171            value_encrypted: None,
7172            credential_ref: None,
7173            configured: true,
7174            description: None,
7175        }]);
7176        live.publish_env_vars();
7177        assert_eq!(
7178            Config::current_env_vars()
7179                .get("BAMBOO_CACHE_OWNER_40")
7180                .map(String::as_str),
7181            Some("live")
7182        );
7183
7184        // A config.json on disk sets the SAME var to a different (stale) value.
7185        let temp = TempHome::new();
7186        temp.set_config_json(
7187            &serde_json::json!({
7188                "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
7189            })
7190            .to_string(),
7191        );
7192
7193        // Non-publishing load reads the disk value into the returned Config but
7194        // must NOT touch the global cache.
7195        let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7196        assert_eq!(
7197            loaded
7198                .env_vars
7199                .iter()
7200                .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
7201                .map(|e| e.value.as_str()),
7202            Some("stale-disk"),
7203            "the returned Config holds the disk value"
7204        );
7205        assert_eq!(
7206            Config::current_env_vars()
7207                .get("BAMBOO_CACHE_OWNER_40")
7208                .map(String::as_str),
7209            Some("live"),
7210            "but the global cache is UNTOUCHED — no clobber (#40)"
7211        );
7212
7213        // Contrast: the publishing variant DOES clobber the cache.
7214        let _ = Config::from_data_dir(Some(temp.path.clone()));
7215        assert_eq!(
7216            Config::current_env_vars()
7217                .get("BAMBOO_CACHE_OWNER_40")
7218                .map(String::as_str),
7219            Some("stale-disk"),
7220            "the publishing loader clobbers the cache (contrast)"
7221        );
7222    }
7223
7224    fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
7225        std::fs::read_dir(dir)
7226            .unwrap()
7227            .filter_map(|e| e.ok())
7228            .any(|e| {
7229                e.file_name()
7230                    .to_string_lossy()
7231                    .contains("config.json.corrupted.")
7232            })
7233    }
7234
7235    #[test]
7236    fn corrupt_config_recovered_from_backup_and_quarantined() {
7237        let temp = TempHome::new();
7238        // Last-known-good backup with a distinctive value.
7239        std::fs::write(
7240            temp.path.join("config.json.bak"),
7241            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7242        )
7243        .unwrap();
7244        // Corrupt primary config.json.
7245        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7246
7247        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7248        assert_eq!(
7249            config.http_proxy, "http://from-backup",
7250            "recovered from config.json.bak instead of losing all config"
7251        );
7252        assert!(
7253            dir_has_quarantine_file(&temp.path),
7254            "corrupt config.json was quarantined (preserved), not discarded"
7255        );
7256    }
7257
7258    #[test]
7259    fn corrupt_config_without_backup_quarantines_then_defaults() {
7260        let temp = TempHome::new();
7261        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7262
7263        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7264        assert!(
7265            config.http_proxy.is_empty(),
7266            "no backup -> falls back to defaults"
7267        );
7268        assert!(
7269            dir_has_quarantine_file(&temp.path),
7270            "corrupt config.json is quarantined even when there's no backup"
7271        );
7272    }
7273
7274    #[test]
7275    fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
7276        let temp = TempHome::new();
7277        // A valid JSON OBJECT, but `env_vars` is the wrong type (string, not array)
7278        // so STRICT parse fails. There is NO config.json.bak, so recovery must come
7279        // from field-level salvage: `http_proxy` is valid and must survive; the bad
7280        // `env_vars` resets to its default.
7281        temp.set_config_json(
7282            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7283        );
7284
7285        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7286        assert_eq!(
7287            config.http_proxy, "http://salvaged",
7288            "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
7289        );
7290        assert!(
7291            config.env_vars.is_empty(),
7292            "the corrupt field reset to its default instead of failing the whole load"
7293        );
7294        assert!(
7295            dir_has_quarantine_file(&temp.path),
7296            "the corrupt config.json was still quarantined for inspection"
7297        );
7298    }
7299
7300    #[test]
7301    fn salvage_preferred_over_backup_for_most_recent_intent() {
7302        let temp = TempHome::new();
7303        // An OLDER last-known-good backup...
7304        std::fs::write(
7305            temp.path.join("config.json.bak"),
7306            serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
7307        )
7308        .unwrap();
7309        // ...and a NEWER config that is corrupt but field-salvageable.
7310        temp.set_config_json(
7311            r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
7312        );
7313
7314        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7315        assert_eq!(
7316            config.http_proxy, "http://new-salvaged",
7317            "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7318        );
7319    }
7320
7321    #[test]
7322    fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7323        let temp = TempHome::new();
7324        // Backup carries TWO good values.
7325        std::fs::write(
7326            temp.path.join("config.json.bak"),
7327            serde_json::json!({
7328                "http_proxy": "http://old-from-backup",
7329                "https_proxy": "https://kept-from-backup",
7330            })
7331            .to_string(),
7332        )
7333        .unwrap();
7334        // The corrupt file updates http_proxy (newer), leaves https_proxy untouched,
7335        // and has one wrong-type field.
7336        temp.set_config_json(
7337            r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7338        );
7339
7340        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7341        // Best of both: the corrupt file's newer valid edit wins where it set one...
7342        assert_eq!(
7343            config.http_proxy, "http://newer-edit",
7344            "the corrupt file's newer valid edit is applied"
7345        );
7346        // ...and the backup's value survives for fields the corrupt file didn't fix.
7347        assert_eq!(
7348            config.https_proxy, "https://kept-from-backup",
7349            "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7350        );
7351    }
7352
7353    #[test]
7354    fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7355        let temp = TempHome::new();
7356        std::fs::write(
7357            temp.path.join("config.json.bak"),
7358            serde_json::json!({
7359                "providers": {
7360                    "anthropic": { "model": "claude-backup" }
7361                },
7362                "memory": {
7363                    "auto_dream_enabled": true
7364                },
7365                "subagents": {
7366                    "claude_code_model": "claude-code-backup"
7367                }
7368            })
7369            .to_string(),
7370        )
7371        .unwrap();
7372        assert!(!temp.path.join("providers.json").exists());
7373        assert!(!temp.path.join("memory.json").exists());
7374        assert!(!temp.path.join("subagents.json").exists());
7375
7376        let (salvaged, recovered_fields) = Config::salvage_partial(
7377            r#"{"providers":"schema-invalid-provider-section"}"#,
7378            &temp.path,
7379        )
7380        .expect("object-shaped corrupt config should be salvageable");
7381
7382        assert!(
7383            recovered_fields.is_empty(),
7384            "the schema-invalid provider field must not replace the backup baseline"
7385        );
7386        assert_eq!(
7387            salvaged
7388                .providers()
7389                .anthropic
7390                .as_ref()
7391                .and_then(|provider| provider.model.as_deref()),
7392            Some("claude-backup")
7393        );
7394        assert!(
7395            salvaged
7396                .memory()
7397                .as_ref()
7398                .expect("backup memory config survives")
7399                .auto_dream_enabled
7400        );
7401        assert_eq!(
7402            salvaged.subagents().claude_code_model.as_deref(),
7403            Some("claude-code-backup")
7404        );
7405    }
7406
7407    #[test]
7408    fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7409        let temp = TempHome::new();
7410        // Not even a JSON object -> nothing field-wise to salvage -> must fall
7411        // through to the .bak (the pre-#135 behavior is preserved).
7412        std::fs::write(
7413            temp.path.join("config.json.bak"),
7414            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7415        )
7416        .unwrap();
7417        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7418
7419        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7420        assert_eq!(
7421            config.http_proxy, "http://from-backup",
7422            "garbage (non-object) config skips salvage and recovers from .bak"
7423        );
7424    }
7425
7426    #[test]
7427    fn quarantine_files_are_capped_to_newest_n() {
7428        let temp = TempHome::new();
7429        let config_path = temp.path.join("config.json");
7430        std::fs::write(&config_path, "{}").unwrap();
7431
7432        // Drop more quarantines than the cap; each call sleeps so nanos (the name)
7433        // and mtime (the prune sort key) are distinct.
7434        for _ in 0..(QUARANTINE_KEEP + 3) {
7435            quarantine_corrupt_config(&config_path);
7436            std::thread::sleep(std::time::Duration::from_millis(3));
7437        }
7438
7439        let count = std::fs::read_dir(&temp.path)
7440            .unwrap()
7441            .filter_map(|e| e.ok())
7442            .filter(|e| {
7443                e.file_name()
7444                    .to_string_lossy()
7445                    .starts_with("config.json.corrupted.")
7446            })
7447            .count();
7448        assert_eq!(
7449            count, QUARANTINE_KEEP,
7450            "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7451        );
7452    }
7453
7454    #[test]
7455    fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7456        let temp = TempHome::new();
7457        // Primary AND the freshest .bak are corrupt; an older generation is good.
7458        std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7459        std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7460        std::fs::write(
7461            temp.path.join("config.json.bak.1"),
7462            serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7463        )
7464        .unwrap();
7465
7466        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7467        assert_eq!(
7468            config.http_proxy, "http://from-gen-1",
7469            "recovered from .bak.1 when both config.json and .bak are corrupt"
7470        );
7471    }
7472
7473    #[test]
7474    fn save_rotates_backup_generations() {
7475        let temp = TempHome::new();
7476        let path = temp.path.join("config.json");
7477        // v1 is the existing on-disk config.
7478        std::fs::write(
7479            &path,
7480            serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7481        )
7482        .unwrap();
7483
7484        let mut cfg = Config::create_default();
7485        // Save 1: backs up the existing v1 -> .bak, writes v2.
7486        cfg.http_proxy = "http://proxy-v2".to_string();
7487        cfg.save_to_dir(temp.path.clone()).unwrap();
7488        // Save 2: existing (v2) is parseable -> rotate .bak(v1) -> .bak.1, .bak = v2.
7489        cfg.http_proxy = "http://proxy-v3".to_string();
7490        cfg.save_to_dir(temp.path.clone()).unwrap();
7491
7492        let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7493        let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7494        assert!(
7495            bak.contains("proxy-v2"),
7496            ".bak holds the previous generation (v2)"
7497        );
7498        assert!(
7499            bak1.contains("proxy-v1"),
7500            ".bak.1 holds the older rotated generation (v1)"
7501        );
7502    }
7503
7504    #[test]
7505    fn save_backs_up_existing_config() {
7506        let temp = TempHome::new();
7507        // Existing (old) config on disk.
7508        std::fs::write(
7509            temp.path.join("config.json"),
7510            serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7511        )
7512        .unwrap();
7513
7514        let mut config = Config::create_default();
7515        config.http_proxy = "http://new".to_string();
7516        config
7517            .save_to_dir(temp.path.clone())
7518            .expect("save succeeds");
7519
7520        let backup =
7521            std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7522        assert!(
7523            backup.contains("http://old"),
7524            "config.json.bak holds the PREVIOUS config (last-known-good)"
7525        );
7526        let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7527        assert!(
7528            current.contains("http://new"),
7529            "config.json holds the new config"
7530        );
7531    }
7532
7533    #[test]
7534    fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7535        let temp = TempHome::new();
7536        // A good last-known-good backup...
7537        std::fs::write(
7538            temp.path.join("config.json.bak"),
7539            serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7540        )
7541        .unwrap();
7542        // ...but the on-disk config.json is corrupt (as it would be right after an
7543        // in-memory recovery, before any clean save).
7544        std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7545
7546        let mut config = Config::create_default();
7547        config.http_proxy = "http://new".to_string();
7548        config
7549            .save_to_dir(temp.path.clone())
7550            .expect("save succeeds");
7551
7552        // The good .bak must NOT have been clobbered by the corrupt config.json.
7553        let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7554        assert!(
7555            backup.contains("http://good-bak"),
7556            "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7557        );
7558    }
7559
7560    // ── config-corruption recovery confirmation gate (#153) ───────────────
7561
7562    #[test]
7563    fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7564        let temp = TempHome::new();
7565        std::fs::write(
7566            temp.path.join("config.json.bak"),
7567            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7568        )
7569        .unwrap();
7570        let corrupt_bytes = "{ not valid json ";
7571        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7572
7573        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7574        let status = config
7575            .recovery_status()
7576            .expect("a corrupt load must set a pending recovery status");
7577        assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7578        assert_eq!(
7579            status.source,
7580            ConfigRecoverySource::Backup { generation: 0 },
7581            "recovered from generation-0 (.bak)"
7582        );
7583        let quarantine_path = status
7584            .quarantine_path
7585            .as_ref()
7586            .expect("quarantine copy should have succeeded");
7587        assert_eq!(
7588            std::fs::read_to_string(quarantine_path).unwrap(),
7589            corrupt_bytes,
7590            "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7591        );
7592        assert_eq!(
7593            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7594            corrupt_bytes,
7595            "the original config.json itself is untouched by the load (only copied, not moved)"
7596        );
7597    }
7598
7599    #[test]
7600    fn recovery_status_set_from_salvage_lists_recovered_fields() {
7601        let temp = TempHome::new();
7602        temp.set_config_json(
7603            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7604        );
7605
7606        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7607        let status = config.recovery_status().expect("pending recovery");
7608        assert!(!status.confirmed);
7609        match &status.source {
7610            ConfigRecoverySource::Salvaged { fields } => {
7611                assert!(
7612                    fields.iter().any(|f| f == "http_proxy"),
7613                    "salvaged fields should list the recovered key: {fields:?}"
7614                );
7615            }
7616            other => panic!("expected Salvaged source, got {other:?}"),
7617        }
7618    }
7619
7620    #[test]
7621    fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7622        let temp = TempHome::new();
7623        // Not a JSON object at all -> salvage impossible; no .bak -> defaults.
7624        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
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        assert_eq!(status.source, ConfigRecoverySource::Defaults);
7630    }
7631
7632    #[test]
7633    fn clean_load_never_sets_recovery_status() {
7634        let temp = TempHome::new();
7635        temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7636
7637        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7638        assert!(
7639            config.recovery_status().is_none(),
7640            "a config.json that parses cleanly must never carry a pending recovery status"
7641        );
7642    }
7643
7644    #[test]
7645    fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7646        let temp = TempHome::new();
7647        let corrupt_bytes = "}}} broken";
7648        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7649
7650        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7651        assert!(config.recovery_status().is_some());
7652
7653        let err = config
7654            .save_to_dir(temp.path.clone())
7655            .expect_err("save must refuse while recovery is unconfirmed");
7656        assert!(
7657            err.to_string().contains("recovered from corruption")
7658                || err.to_string().contains("confirm"),
7659            "error should explain the refused overwrite: {err}"
7660        );
7661
7662        // The corrupt original on disk must be BYTE FOR BYTE unchanged — the
7663        // refused save must not have touched it at all.
7664        assert_eq!(
7665            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7666            corrupt_bytes,
7667            "a refused save must leave the corrupt original untouched"
7668        );
7669    }
7670
7671    #[test]
7672    fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7673        let temp = TempHome::new();
7674        // Simulates a crash mid-write: valid JSON prefix, abruptly cut off.
7675        let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7676        std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7677
7678        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7679        let status = config.recovery_status().expect("pending recovery");
7680        let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7681        assert_eq!(
7682            std::fs::read_to_string(quarantine_path).unwrap(),
7683            truncated,
7684            "truncated original preserved byte for byte in quarantine"
7685        );
7686
7687        let err = config.save_to_dir(temp.path.clone());
7688        assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7689        assert_eq!(
7690            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7691            truncated,
7692            "the half-written original stays exactly as it was after a refused save"
7693        );
7694    }
7695
7696    #[test]
7697    fn confirm_recovery_allows_the_next_save() {
7698        let temp = TempHome::new();
7699        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7700
7701        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7702        assert!(config.recovery_status().is_some());
7703
7704        config.confirm_recovery();
7705        assert!(
7706            config.recovery_status().is_some_and(|s| s.confirmed),
7707            "confirm_recovery flips the flag but keeps the status around"
7708        );
7709
7710        config
7711            .save_to_dir(temp.path.clone())
7712            .expect("save must succeed once the recovery is confirmed");
7713    }
7714
7715    #[test]
7716    fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7717        let temp = TempHome::new();
7718        std::fs::write(
7719            temp.path.join("config.json"),
7720            r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7721        )
7722        .unwrap();
7723
7724        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7725        assert!(config.recovery_status().is_some());
7726        let quarantine_path = config
7727            .recovery_status()
7728            .unwrap()
7729            .quarantine_path
7730            .clone()
7731            .unwrap();
7732
7733        config
7734            .confirm_recovery_and_save_to_dir(temp.path.clone())
7735            .expect("confirm+save should succeed");
7736
7737        assert!(
7738            config.recovery_status().is_none(),
7739            "the pending flag is cleared once the recovery is confirmed and persisted"
7740        );
7741        let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7742        assert!(
7743            on_disk.contains("http://recovered"),
7744            "config.json now holds the recovered (salvaged) state"
7745        );
7746        // The quarantine copy of the original corrupt file must still exist,
7747        // untouched, even after the recovery is confirmed and persisted.
7748        assert!(
7749            quarantine_path.exists(),
7750            "the quarantined original survives confirmation — it's never deleted"
7751        );
7752    }
7753
7754    #[test]
7755    fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7756        let temp = TempHome::new();
7757        let mut config = Config::create_default();
7758        let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7759        assert!(
7760            err.is_err(),
7761            "confirming a recovery that was never pending must error, not silently succeed"
7762        );
7763    }
7764
7765    // ── connect.json split (#455) ────────────────────────────────────────
7766
7767    fn connect_platform_with_encrypted(
7768        platform_type: &str,
7769        token_encrypted: &str,
7770    ) -> ConnectPlatformConfig {
7771        ConnectPlatformConfig {
7772            id: None,
7773            project_id: None,
7774            platform_type: platform_type.to_string(),
7775            token: None,
7776            token_encrypted: Some(token_encrypted.to_string()),
7777            token_credential_ref: None,
7778            token_configured: false,
7779            app_id: None,
7780            app_secret: None,
7781            app_secret_encrypted: None,
7782            app_secret_credential_ref: None,
7783            app_secret_configured: false,
7784            domain: None,
7785            allow_from: vec!["user-1".to_string()],
7786            admin_from: Vec::new(),
7787        }
7788    }
7789
7790    fn connect_json_path(temp: &TempHome) -> PathBuf {
7791        temp.path.join("connect.json")
7792    }
7793
7794    #[test]
7795    fn save_splits_connect_into_sibling_connect_json() {
7796        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7797        let temp = TempHome::new();
7798
7799        let mut config = Config::create_default();
7800        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7801        config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7802
7803        config
7804            .save_to_dir(temp.path.clone())
7805            .expect("save succeeds");
7806
7807        let config_json: serde_json::Value =
7808            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7809                .unwrap();
7810        assert!(
7811            config_json.get("connect").is_none(),
7812            "config.json must not carry the `connect` key after a save"
7813        );
7814
7815        let connect_json: serde_json::Value =
7816            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7817                .unwrap();
7818        assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7819        assert!(
7820            connect_json["platforms"][0]["token_encrypted"]
7821                .as_str()
7822                .is_some_and(|v| !v.is_empty()),
7823            "the token is persisted in its encrypted form in connect.json"
7824        );
7825        assert!(
7826            connect_json["platforms"][0].get("token").is_none(),
7827            "the plaintext token is never persisted (skip_serializing)"
7828        );
7829    }
7830
7831    // ── stable connect.platforms id (#496) ───────────────────────────────
7832
7833    #[test]
7834    fn save_assigns_a_missing_connect_platform_id() {
7835        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7836        let temp = TempHome::new();
7837
7838        let mut config = Config::create_default();
7839        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7840        assert!(
7841            config.connect.platforms[0].id.is_none(),
7842            "precondition: the entry starts without an id"
7843        );
7844
7845        config
7846            .save_to_dir(temp.path.clone())
7847            .expect("save succeeds");
7848
7849        let connect_json: serde_json::Value =
7850            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7851                .unwrap();
7852        let persisted_id = connect_json["platforms"][0]["id"]
7853            .as_str()
7854            .expect("save_to_dir must backfill a missing id onto the persisted entry");
7855        assert!(!persisted_id.is_empty());
7856
7857        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7858        assert_eq!(
7859            reloaded.connect.platforms[0].id.as_deref(),
7860            Some(persisted_id),
7861            "the assigned id round-trips through a reload"
7862        );
7863    }
7864
7865    #[test]
7866    fn save_never_reassigns_an_existing_connect_platform_id() {
7867        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7868        let temp = TempHome::new();
7869
7870        let mut config = Config::create_default();
7871        let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7872        platform.id = Some("stable-id-123".to_string());
7873        config.connect.platforms = vec![platform];
7874
7875        config
7876            .save_to_dir(temp.path.clone())
7877            .expect("first save succeeds");
7878        // Save again (e.g. an unrelated settings change) — the id must not change.
7879        config
7880            .save_to_dir(temp.path.clone())
7881            .expect("second save succeeds");
7882
7883        let connect_json: serde_json::Value =
7884            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7885                .unwrap();
7886        assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7887    }
7888
7889    #[test]
7890    fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7891        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7892        let temp = TempHome::new();
7893
7894        let mut config = Config::create_default();
7895        config.connect.platforms = vec![
7896            connect_platform_with_encrypted("telegram", "cipher-a"),
7897            connect_platform_with_encrypted("telegram", "cipher-b"),
7898        ];
7899
7900        config
7901            .save_to_dir(temp.path.clone())
7902            .expect("save succeeds");
7903
7904        let connect_json: serde_json::Value =
7905            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7906                .unwrap();
7907        let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7908        let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7909        assert_ne!(
7910            id_a, id_b,
7911            "two entries sharing platform_type must still get distinct ids"
7912        );
7913    }
7914
7915    #[test]
7916    fn load_never_assigns_or_persists_an_id_by_itself() {
7917        let temp = TempHome::new();
7918        std::fs::write(
7919            connect_json_path(&temp),
7920            serde_json::json!({
7921                "platforms": [
7922                    { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7923                ]
7924            })
7925            .to_string(),
7926        )
7927        .unwrap();
7928        let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7929
7930        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7931
7932        assert!(
7933            config.connect.platforms[0].id.is_none(),
7934            "load alone must not backfill an id in memory"
7935        );
7936        let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7937        assert_eq!(
7938            connect_json_before, connect_json_after,
7939            "load must never rewrite connect.json on disk just to backfill an id (#493)"
7940        );
7941    }
7942
7943    #[test]
7944    fn load_merges_connect_json_into_config() {
7945        let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7946        let temp = TempHome::new();
7947        let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7948        std::fs::write(
7949            connect_json_path(&temp),
7950            serde_json::json!({
7951                "platforms": [
7952                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7953                ]
7954            })
7955            .to_string(),
7956        )
7957        .unwrap();
7958
7959        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7960        assert_eq!(config.connect.platforms.len(), 1);
7961        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7962        assert_eq!(
7963            config.connect.platforms[0].token.as_deref(),
7964            Some("connect-secret")
7965        );
7966        assert!(config.connect.platforms[0].token_encrypted.is_none());
7967    }
7968
7969    #[test]
7970    fn load_without_connect_json_yields_empty_inert_connect_config() {
7971        let temp = TempHome::new();
7972        temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7973
7974        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7975        assert!(
7976            config.connect.platforms.is_empty(),
7977            "no connect.json and no legacy key -> empty/inert connect config"
7978        );
7979        assert!(
7980            !connect_json_path(&temp).exists(),
7981            "load must not create connect.json when there is nothing to migrate"
7982        );
7983    }
7984
7985    #[test]
7986    fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7987        let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7988        let temp = TempHome::new();
7989        let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7990        let legacy_cipher_for_assert = legacy_cipher.clone();
7991        // Legacy state (#453): connect lives inline in config.json, no connect.json yet.
7992        temp.set_config_json(
7993            &serde_json::json!({
7994                "http_proxy": "http://keep-me",
7995                "connect": {
7996                    "platforms": [
7997                        { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7998                    ]
7999                }
8000            })
8001            .to_string(),
8002        );
8003
8004        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8005
8006        // In-memory: legacy value adopted.
8007        assert_eq!(config.connect.platforms.len(), 1);
8008        assert_eq!(
8009            config.connect.platforms[0].token.as_deref(),
8010            Some("legacy-connect-secret")
8011        );
8012        // An unrelated field from the same load survives the migration rewrite.
8013        assert_eq!(config.http_proxy, "http://keep-me");
8014
8015        // On disk: connect.json was created...
8016        assert!(
8017            connect_json_path(&temp).exists(),
8018            "migration proactively creates connect.json"
8019        );
8020        let connect_json: serde_json::Value =
8021            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8022                .unwrap();
8023        assert_eq!(
8024            connect_json["platforms"][0]["token_encrypted"],
8025            legacy_cipher_for_assert
8026        );
8027
8028        // ...and config.json was rewritten without the `connect` key.
8029        let config_json: serde_json::Value =
8030            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8031                .unwrap();
8032        assert!(
8033            config_json.get("connect").is_none(),
8034            "config.json is rewritten without the legacy `connect` key"
8035        );
8036    }
8037
8038    /// #457: the legacy-key migration must be a NARROW write (strip `connect`
8039    /// from config.json + write connect.json) — not the full `save_to_dir`,
8040    /// which would re-encrypt every OTHER secret in config.json and rotate a
8041    /// `config.json.bak` generation as a load-time side effect. This matters
8042    /// most for a purely READ-ONLY command (e.g. `bamboo config get`) run on a
8043    /// machine that still has the legacy `connect` key: it must not silently
8044    /// rewrite/re-encrypt unrelated secrets or spin up a backup.
8045    #[test]
8046    fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
8047        let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
8048        let temp = TempHome::new();
8049
8050        let original_api_key_encrypted =
8051            crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
8052        temp.set_config_json(
8053            &serde_json::json!({
8054                "providers": {
8055                    "openai": {
8056                        "api_key_encrypted": original_api_key_encrypted,
8057                    }
8058                },
8059                "connect": {
8060                    "platforms": [
8061                        { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
8062                    ]
8063                }
8064            })
8065            .to_string(),
8066        );
8067
8068        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8069        assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
8070
8071        // No `config.json.bak` — the narrow write does not rotate backups the
8072        // way a full `save_to_dir` would.
8073        assert!(
8074            !temp.path.join("config.json.bak").exists(),
8075            "a read-only load migrating a legacy `connect` key must not rotate \
8076             config.json backups"
8077        );
8078
8079        // The unrelated provider secret's ciphertext is byte-for-byte
8080        // unchanged — proof it was never decrypted+re-encrypted (encryption
8081        // uses a random nonce per call, so any re-encryption would change the
8082        // bytes even for the same plaintext).
8083        let config_json: serde_json::Value =
8084            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8085                .unwrap();
8086        assert_eq!(
8087            config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
8088            "an unrelated secret's ciphertext must not be touched by the connect \
8089             migration's narrow write"
8090        );
8091        assert!(
8092            config_json.get("connect").is_none(),
8093            "config.json is still rewritten without the legacy `connect` key"
8094        );
8095    }
8096
8097    #[test]
8098    fn both_files_present_connect_json_wins() {
8099        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
8100        let temp = TempHome::new();
8101        let stale = crate::encryption::encrypt("stale-secret").unwrap();
8102        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8103        temp.set_config_json(
8104            &serde_json::json!({
8105                "connect": {
8106                    "platforms": [
8107                        { "type": "telegram", "token_encrypted": stale }
8108                    ]
8109                }
8110            })
8111            .to_string(),
8112        );
8113        std::fs::write(
8114            connect_json_path(&temp),
8115            serde_json::json!({
8116                "platforms": [
8117                    { "type": "telegram", "token_encrypted": authoritative }
8118                ]
8119            })
8120            .to_string(),
8121        )
8122        .unwrap();
8123
8124        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8125        assert_eq!(
8126            config.connect.platforms[0].token.as_deref(),
8127            Some("authoritative-secret"),
8128            "connect.json wins over a stale legacy config.json key"
8129        );
8130    }
8131
8132    /// #457: when both files are present, the superseded `connect` key in
8133    /// config.json must be stripped PROACTIVELY on load — not left to linger
8134    /// until the next natural save, which spreads token ciphertext across two
8135    /// files for longer than necessary.
8136    #[test]
8137    fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
8138        let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
8139        let temp = TempHome::new();
8140        let stale = crate::encryption::encrypt("stale-secret").unwrap();
8141        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8142        temp.set_config_json(
8143            &serde_json::json!({
8144                "http_proxy": "http://keep-me",
8145                "connect": {
8146                    "platforms": [
8147                        { "type": "telegram", "token_encrypted": stale }
8148                    ]
8149                }
8150            })
8151            .to_string(),
8152        );
8153        std::fs::write(
8154            connect_json_path(&temp),
8155            serde_json::json!({
8156                "platforms": [
8157                    { "type": "telegram", "token_encrypted": authoritative }
8158                ]
8159            })
8160            .to_string(),
8161        )
8162        .unwrap();
8163
8164        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8165        assert_eq!(
8166            config.connect.platforms[0].token.as_deref(),
8167            Some("authoritative-secret")
8168        );
8169        // Unrelated field survives the strip.
8170        assert_eq!(config.http_proxy, "http://keep-me");
8171
8172        let config_json: serde_json::Value =
8173            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8174                .unwrap();
8175        assert!(
8176            config_json.get("connect").is_none(),
8177            "the stale legacy `connect` key must be stripped from config.json \
8178             immediately on load, not left for the next natural save"
8179        );
8180    }
8181
8182    /// #468 (follow-up to #457): a `.bak` generation that predates the #455
8183    /// split can still carry the legacy embedded `connect` sub-tree even
8184    /// after the LIVE config.json has long since been migrated (a clean
8185    /// config.json here, with no `connect` key at all, proves the sweep does
8186    /// not depend on the current-load migration path having just fired).
8187    /// Only the tainted generation is rewritten; every other key in it
8188    /// survives, and the rewrite strips exactly the `connect` key.
8189    #[test]
8190    fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
8191        let temp = TempHome::new();
8192        temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
8193        std::fs::write(
8194            temp.path.join("config.json.bak"),
8195            serde_json::json!({
8196                "http_proxy": "http://old",
8197                "connect": {
8198                    "platforms": [
8199                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
8200                    ]
8201                }
8202            })
8203            .to_string(),
8204        )
8205        .unwrap();
8206
8207        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8208        // The live config is unaffected — connect.json never existed and
8209        // config.json never had the key, so in-memory connect stays empty.
8210        assert!(config.connect.platforms.is_empty());
8211
8212        let bak: serde_json::Value = serde_json::from_str(
8213            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8214        )
8215        .unwrap();
8216        assert!(
8217            bak.get("connect").is_none(),
8218            "the legacy `connect` key must be stripped from the tainted .bak generation"
8219        );
8220        assert_eq!(
8221            bak["http_proxy"], "http://old",
8222            "every other key in the .bak generation survives the scrub byte-for-byte in content"
8223        );
8224    }
8225
8226    /// The sweep must touch EVERY rotated generation that carries the legacy
8227    /// key, not just `.bak` — an upgraded instance can have the taint several
8228    /// generations deep depending on how many saves happened since #455/#457
8229    /// shipped but before this fix.
8230    #[test]
8231    fn scrub_reaches_all_rotated_generations() {
8232        let temp = TempHome::new();
8233        temp.set_config_json(&serde_json::json!({}).to_string());
8234        for (gen_suffix, cipher) in [
8235            ("config.json.bak", "cipher-gen0"),
8236            ("config.json.bak.1", "cipher-gen1"),
8237            ("config.json.bak.2", "cipher-gen2"),
8238        ] {
8239            std::fs::write(
8240                temp.path.join(gen_suffix),
8241                serde_json::json!({
8242                    "connect": {
8243                        "platforms": [
8244                            { "type": "telegram", "token_encrypted": cipher }
8245                        ]
8246                    }
8247                })
8248                .to_string(),
8249            )
8250            .unwrap();
8251        }
8252
8253        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8254
8255        for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
8256            let value: serde_json::Value =
8257                serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
8258                    .unwrap();
8259            assert!(
8260                value.get("connect").is_none(),
8261                "{gen_suffix} must have its legacy `connect` key stripped"
8262            );
8263        }
8264    }
8265
8266    /// A `.bak` generation with NO legacy `connect` key must be left
8267    /// completely untouched by the sweep — not even a byte-identical
8268    /// rewrite — preserving the file's bytes/mtime exactly. This is the
8269    /// overwhelming common case (any backup created after #455/#457 shipped)
8270    /// and the whole point of the surgical, only-touch-what's-tainted
8271    /// approach: `.bak` files are the user's recovery net (#493) and
8272    /// shouldn't be churned by an unrelated sweep.
8273    #[test]
8274    fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
8275        let temp = TempHome::new();
8276        temp.set_config_json(&serde_json::json!({}).to_string());
8277        let bak_path = temp.path.join("config.json.bak");
8278        std::fs::write(
8279            &bak_path,
8280            serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
8281        )
8282        .unwrap();
8283
8284        let before_bytes = std::fs::read(&bak_path).unwrap();
8285        let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8286
8287        // A tiny sleep would make an mtime-changed assertion more robust, but
8288        // even without one, a same-mtime filesystem is the STRONGER
8289        // guarantee of "no write happened" — good enough on its own.
8290        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8291
8292        let after_bytes = std::fs::read(&bak_path).unwrap();
8293        let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8294        assert_eq!(
8295            before_bytes, after_bytes,
8296            "a .bak generation without a legacy `connect` key must not be rewritten at all"
8297        );
8298        assert_eq!(
8299            before_mtime, after_mtime,
8300            "no write means no mtime change either"
8301        );
8302    }
8303
8304    /// An unparsable `.bak` generation (corrupt/foreign content) must be
8305    /// skipped, not deleted and not guessed at — it's left exactly as found
8306    /// so an operator can inspect it by hand, matching the same fail-safe
8307    /// posture as the rest of the backup/quarantine machinery.
8308    #[test]
8309    fn scrub_skips_unparsable_backup_without_deleting_it() {
8310        let temp = TempHome::new();
8311        temp.set_config_json(&serde_json::json!({}).to_string());
8312        std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
8313
8314        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8315
8316        let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8317        assert_eq!(
8318            content, "{ not valid json",
8319            "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8320        );
8321    }
8322
8323    /// A missing generation (e.g. only `.bak` exists, no `.bak.1`/`.bak.2`
8324    /// yet) must not trip an error — it's the common case for a young
8325    /// install and the sweep should just skip straight past it.
8326    #[test]
8327    fn scrub_tolerates_missing_generations() {
8328        let temp = TempHome::new();
8329        temp.set_config_json(&serde_json::json!({}).to_string());
8330        // No .bak files at all.
8331        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8332        assert!(config.connect.platforms.is_empty());
8333        assert!(!temp.path.join("config.json.bak").exists());
8334    }
8335
8336    /// The scrub sweep must not interfere with normal backup rotation on
8337    /// subsequent saves — rotation keeps working exactly as before.
8338    #[test]
8339    fn scrub_does_not_break_backup_rotation() {
8340        let temp = TempHome::new();
8341        std::fs::write(
8342            temp.path.join("config.json"),
8343            serde_json::json!({
8344                "http_proxy": "http://proxy-v1",
8345                "connect": {
8346                    "platforms": [
8347                        { "type": "telegram", "token_encrypted": "legacy-cipher" }
8348                    ]
8349                }
8350            })
8351            .to_string(),
8352        )
8353        .unwrap();
8354        std::fs::write(
8355            temp.path.join("config.json.bak"),
8356            serde_json::json!({
8357                "http_proxy": "http://proxy-v0",
8358                "connect": {
8359                    "platforms": [
8360                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8361                    ]
8362                }
8363            })
8364            .to_string(),
8365        )
8366        .unwrap();
8367
8368        // Load triggers: migration of the live legacy key + the .bak sweep.
8369        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8370        let bak: serde_json::Value = serde_json::from_str(
8371            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8372        )
8373        .unwrap();
8374        assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8375
8376        // Rotation still works on a subsequent save: v_current -> .bak,
8377        // .bak(old) -> .bak.1.
8378        config.http_proxy = "http://proxy-v2".to_string();
8379        config.save_to_dir(temp.path.clone()).unwrap();
8380
8381        let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8382        assert!(
8383            new_bak.contains("proxy-v1"),
8384            ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8385        );
8386        let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8387        assert!(
8388            new_bak1.contains("proxy-v0"),
8389            ".bak.1 holds the scrubbed older generation after rotation"
8390        );
8391        assert!(
8392            !new_bak1.contains("legacy-bak-cipher"),
8393            "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8394             stripped secret"
8395        );
8396    }
8397
8398    #[test]
8399    fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8400        let temp = TempHome::new();
8401        temp.set_config_json("{}");
8402        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8403
8404        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8405        assert!(
8406            config.connect.platforms.is_empty(),
8407            "corrupt connect.json fails SAFE to an empty/inert connect config"
8408        );
8409
8410        let backup = connect_json_path(&temp).with_extension("json.bak");
8411        assert!(
8412            backup.exists(),
8413            "the corrupt connect.json is quarantined to connect.json.bak"
8414        );
8415        assert!(
8416            std::fs::read_to_string(backup)
8417                .unwrap()
8418                .contains("not valid json"),
8419            "the quarantined copy holds the bad content"
8420        );
8421        // #457: quarantine MOVES the corrupt file rather than copying it, so
8422        // the data dir doesn't end up with two copies of the same corrupt
8423        // content (the live `connect.json` and its `.bak`) sitting side by
8424        // side, which reads as confusing/ambiguous mid-incident.
8425        assert!(
8426            !connect_json_path(&temp).exists(),
8427            "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8428             connect.json should remain after quarantine"
8429        );
8430    }
8431
8432    #[test]
8433    fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8434        let temp = TempHome::new();
8435        // A legacy inline `connect` key is present too — it must NOT be used as a
8436        // fallback when connect.json is corrupt (security-sensitive: fail safe).
8437        temp.set_config_json(
8438            &serde_json::json!({
8439                "connect": {
8440                    "platforms": [
8441                        { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8442                    ]
8443                }
8444            })
8445            .to_string(),
8446        );
8447        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8448
8449        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8450        assert!(
8451            config.connect.platforms.is_empty(),
8452            "corrupt connect.json must not fall back to the legacy config.json copy"
8453        );
8454    }
8455
8456    #[test]
8457    fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8458        let temp = TempHome::new();
8459        let config = Config::create_default();
8460        assert!(config.connect.platforms.is_empty());
8461
8462        config
8463            .save_to_dir(temp.path.clone())
8464            .expect("save succeeds");
8465
8466        assert!(
8467            !connect_json_path(&temp).exists(),
8468            "an empty connect config with no pre-existing file must not create one"
8469        );
8470    }
8471
8472    #[test]
8473    fn connect_json_backed_up_before_overwrite() {
8474        let temp = TempHome::new();
8475        std::fs::write(
8476            connect_json_path(&temp),
8477            serde_json::json!({
8478                "platforms": [
8479                    { "type": "telegram", "token_encrypted": "old-cipher" }
8480                ]
8481            })
8482            .to_string(),
8483        )
8484        .unwrap();
8485
8486        let mut config = Config::create_default();
8487        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8488        config
8489            .save_to_dir(temp.path.clone())
8490            .expect("save succeeds");
8491
8492        let backup = connect_json_path(&temp).with_extension("json.bak");
8493        assert!(
8494            std::fs::read_to_string(backup)
8495                .unwrap()
8496                .contains("old-cipher"),
8497            "the previous connect.json is preserved as connect.json.bak before the overwrite"
8498        );
8499        let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8500        assert!(current.contains("new-cipher"));
8501    }
8502
8503    // ── Feishu adapter config fields (epic #447 phase 3, §2a) ───────────
8504
8505    #[test]
8506    fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8507        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8508        let temp = TempHome::new();
8509
8510        let mut config = Config::create_default();
8511        config.connect.platforms = vec![ConnectPlatformConfig {
8512            id: None,
8513            project_id: None,
8514            platform_type: "feishu".to_string(),
8515            token: None,
8516            token_encrypted: None,
8517            token_credential_ref: None,
8518            token_configured: false,
8519            app_id: Some("cli_real_app_id".to_string()),
8520            app_secret: Some("plain-app-secret".to_string()),
8521            app_secret_encrypted: None,
8522            app_secret_credential_ref: None,
8523            app_secret_configured: false,
8524            domain: Some("lark".to_string()),
8525            allow_from: vec!["ou_1".to_string()],
8526            admin_from: Vec::new(),
8527        }];
8528
8529        config
8530            .save_to_dir(temp.path.clone())
8531            .expect("save succeeds");
8532
8533        let connect_json: serde_json::Value =
8534            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8535                .unwrap();
8536        assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8537        assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8538        assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8539        assert!(
8540            connect_json["platforms"][0]["app_secret_encrypted"]
8541                .as_str()
8542                .is_some_and(|v| !v.is_empty()),
8543            "app_secret is persisted in its encrypted form in connect.json"
8544        );
8545        assert!(
8546            connect_json["platforms"][0].get("app_secret").is_none(),
8547            "the plaintext app_secret is never persisted (skip_serializing)"
8548        );
8549    }
8550
8551    #[test]
8552    fn load_hydrates_feishu_app_secret_from_encrypted() {
8553        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8554        let temp = TempHome::new();
8555
8556        let mut config = Config::create_default();
8557        config.connect.platforms = vec![ConnectPlatformConfig {
8558            id: None,
8559            project_id: None,
8560            platform_type: "feishu".to_string(),
8561            token: None,
8562            token_encrypted: None,
8563            token_credential_ref: None,
8564            token_configured: false,
8565            app_id: Some("cli_real_app_id".to_string()),
8566            app_secret: Some("plain-app-secret".to_string()),
8567            app_secret_encrypted: None,
8568            app_secret_credential_ref: None,
8569            app_secret_configured: false,
8570            domain: Some("lark".to_string()),
8571            allow_from: vec!["ou_1".to_string()],
8572            admin_from: Vec::new(),
8573        }];
8574        config
8575            .save_to_dir(temp.path.clone())
8576            .expect("save succeeds");
8577
8578        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8579        assert_eq!(reloaded.connect.platforms.len(), 1);
8580        assert_eq!(
8581            reloaded.connect.platforms[0].app_secret.as_deref(),
8582            Some("plain-app-secret"),
8583            "reload hydrates app_secret from app_secret_encrypted"
8584        );
8585        assert_eq!(
8586            reloaded.connect.platforms[0].app_id.as_deref(),
8587            Some("cli_real_app_id")
8588        );
8589        assert_eq!(
8590            reloaded.connect.platforms[0].domain.as_deref(),
8591            Some("lark")
8592        );
8593    }
8594
8595    #[test]
8596    fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8597        let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8598        let temp = TempHome::new();
8599        let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8600        std::fs::write(
8601            connect_json_path(&temp),
8602            serde_json::json!({
8603                "platforms": [
8604                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8605                ]
8606            })
8607            .to_string(),
8608        )
8609        .unwrap();
8610
8611        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8612
8613        assert_eq!(config.connect.platforms.len(), 1);
8614        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8615        assert_eq!(
8616            config.connect.platforms[0].token.as_deref(),
8617            Some("legacy-telegram-secret")
8618        );
8619        assert_eq!(
8620            config.connect.platforms[0].app_id, None,
8621            "a legacy entry with no Feishu fields deserializes them as None"
8622        );
8623        assert_eq!(config.connect.platforms[0].app_secret, None);
8624        assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8625        assert_eq!(config.connect.platforms[0].domain, None);
8626    }
8627
8628    #[test]
8629    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8630        let _lock = env_lock_acquire();
8631        let temp_home = TempHome::new();
8632        temp_home.set_config_json(
8633            r#"{
8634  "provider": "openai",
8635  "providers": {
8636    "openai": {
8637      "api_key": "sk-test",
8638      "model": "gpt-4o"
8639    }
8640  }
8641}"#,
8642        );
8643
8644        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8645        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8646
8647        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8648
8649        assert_eq!(
8650            config
8651                .providers
8652                .openai
8653                .as_ref()
8654                .and_then(|c| c.model.as_deref()),
8655            Some("gpt-4o")
8656        );
8657        assert!(
8658            config.http_proxy.is_empty(),
8659            "config should keep http_proxy empty when field is omitted"
8660        );
8661        assert!(
8662            config.https_proxy.is_empty(),
8663            "config should keep https_proxy empty when field is omitted"
8664        );
8665    }
8666
8667    #[test]
8668    fn get_memory_background_model_prefers_memory_specific_override() {
8669        let mut config = Config::default();
8670        config.features.provider_model_ref = false;
8671        config.provider = "openai".to_string();
8672        config.providers.openai = Some(OpenAIConfig {
8673            api_key: "test".to_string(),
8674            api_key_encrypted: None,
8675            credential_ref: None,
8676            base_url: None,
8677            model: Some("gpt-main".to_string()),
8678            fast_model: Some("gpt-fast".to_string()),
8679            vision_model: None,
8680            reasoning_effort: None,
8681            responses_only_models: vec![],
8682            request_overrides: None,
8683            extra: BTreeMap::new(),
8684            api_key_from_env: false,
8685        });
8686        config.memory.0 = Some(MemoryConfig {
8687            background_model: Some("memory-fast".to_string()),
8688            ..MemoryConfig::default()
8689        });
8690
8691        assert_eq!(
8692            config.get_memory_background_model().as_deref(),
8693            Some("memory-fast")
8694        );
8695    }
8696
8697    #[test]
8698    fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8699        // #373: the settings-PATCH serde round-trip drops every provider's
8700        // skip_serializing api_key; an env-sourced key (no ciphertext) can't be
8701        // re-hydrated, so it must be copied back from the live `current` config —
8702        // but an explicitly re-set key and non-env keys must NOT be touched.
8703        let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8704            api_key: api_key.to_string(),
8705            api_key_encrypted: None,
8706            credential_ref: None,
8707            base_url: None,
8708            model: None,
8709            fast_model: None,
8710            vision_model: None,
8711            reasoning_effort: None,
8712            responses_only_models: vec![],
8713            request_overrides: None,
8714            extra: BTreeMap::new(),
8715            api_key_from_env: from_env,
8716        };
8717
8718        // Env-sourced key dropped by the round-trip → restored.
8719        let mut current = Config::default();
8720        current.providers.openai = Some(openai("sk-env", true));
8721        let mut merged = Config::default();
8722        merged.providers.openai = Some(openai("", false)); // post-round-trip
8723        merged.preserve_env_sourced_provider_keys(&current);
8724        let got = merged.providers.openai.as_ref().unwrap();
8725        assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8726        assert!(got.api_key_from_env, "env flag restored");
8727
8728        // A key explicitly re-set by the patch is NOT overridden.
8729        let mut merged = Config::default();
8730        merged.providers.openai = Some(openai("sk-explicit", false));
8731        merged.preserve_env_sourced_provider_keys(&current);
8732        assert_eq!(
8733            merged.providers.openai.as_ref().unwrap().api_key,
8734            "sk-explicit",
8735            "explicit patch key must win"
8736        );
8737
8738        // A non-env key in current is NOT restored here (that's ciphertext hydration's job).
8739        let mut current_plain = Config::default();
8740        current_plain.providers.openai = Some(openai("sk-plain", false));
8741        let mut merged = Config::default();
8742        merged.providers.openai = Some(openai("", false));
8743        merged.preserve_env_sourced_provider_keys(&current_plain);
8744        assert!(
8745            merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8746            "non-env key must not be restored by this path"
8747        );
8748    }
8749
8750    #[test]
8751    fn refresh_preserves_ciphertext_when_plaintext_empty() {
8752        // #268: a provider whose stored ciphertext failed to decrypt at hydration
8753        // has an empty in-memory api_key. An unrelated later save must NOT null its
8754        // ciphertext — that would permanently drop a key the user never touched.
8755        let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8756            api_key: api_key.to_string(),
8757            api_key_encrypted: enc.map(str::to_string),
8758            credential_ref: None,
8759            base_url: None,
8760            model: None,
8761            fast_model: None,
8762            vision_model: None,
8763            reasoning_effort: None,
8764            responses_only_models: vec![],
8765            request_overrides: None,
8766            extra: BTreeMap::new(),
8767            api_key_from_env: false,
8768        };
8769
8770        // Empty plaintext + existing ciphertext → ciphertext preserved (the bug).
8771        let mut config = Config::default();
8772        config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8773        config
8774            .refresh_provider_api_keys_encrypted()
8775            .expect("refresh");
8776        assert_eq!(
8777            config
8778                .providers
8779                .openai
8780                .as_ref()
8781                .unwrap()
8782                .api_key_encrypted
8783                .as_deref(),
8784            Some("preexisting-ciphertext"),
8785            "existing ciphertext must be preserved when plaintext is empty"
8786        );
8787
8788        // Empty plaintext + no ciphertext → stays None (nothing to preserve).
8789        let mut config = Config::default();
8790        config.providers.openai = Some(openai("", None));
8791        config
8792            .refresh_provider_api_keys_encrypted()
8793            .expect("refresh");
8794        assert!(
8795            config
8796                .providers
8797                .openai
8798                .as_ref()
8799                .unwrap()
8800                .api_key_encrypted
8801                .is_none(),
8802            "no key + no ciphertext should stay None"
8803        );
8804
8805        // Non-empty plaintext → (re)encrypted to a fresh, non-empty ciphertext.
8806        let mut config = Config::default();
8807        config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8808        config
8809            .refresh_provider_api_keys_encrypted()
8810            .expect("refresh");
8811        let enc = config
8812            .providers
8813            .openai
8814            .as_ref()
8815            .unwrap()
8816            .api_key_encrypted
8817            .clone()
8818            .expect("ciphertext present");
8819        assert!(
8820            !enc.is_empty() && enc != "stale-ciphertext",
8821            "plaintext re-encrypted"
8822        );
8823    }
8824
8825    #[test]
8826    fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8827        // #516: `save_to_dir` refreshes ciphertext only on its save-time clone,
8828        // so a provider instance created over HTTP stays plaintext-only in the
8829        // live config. Serializing that live config (as the settings-PATCH
8830        // merge does) drops the `skip_serializing` plaintext and the key is
8831        // gone. `refresh_encrypted_secrets` on the live config closes the gap.
8832        let mut config = Config::default();
8833        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8834            "provider_type": "openai",
8835            "api_key": "sk-instance-live",
8836        }))
8837        .expect("valid instance");
8838        config
8839            .provider_instances
8840            .insert("work".to_string(), instance);
8841
8842        config.refresh_encrypted_secrets().expect("refresh");
8843        assert!(
8844            config.provider_instances["work"]
8845                .api_key_encrypted
8846                .is_some(),
8847            "live config must hold ciphertext after refresh"
8848        );
8849
8850        // The build_merged_config-style round-trip.
8851        let value = serde_json::to_value(&config).expect("serialize");
8852        let mut back: Config = serde_json::from_value(value).expect("deserialize");
8853        assert!(
8854            back.provider_instances["work"].api_key.is_empty(),
8855            "plaintext is skip_serializing"
8856        );
8857        back.hydrate_provider_instance_api_keys_from_encrypted();
8858        assert_eq!(
8859            back.provider_instances["work"].api_key, "sk-instance-live",
8860            "key must be recoverable from the round-tripped ciphertext"
8861        );
8862    }
8863
8864    #[test]
8865    fn get_memory_background_model_falls_back_to_provider_fast_model() {
8866        let mut config = Config::default();
8867        config.features.provider_model_ref = false;
8868        config.provider = "openai".to_string();
8869        config.providers.openai = Some(OpenAIConfig {
8870            api_key: "test".to_string(),
8871            api_key_encrypted: None,
8872            credential_ref: None,
8873            base_url: None,
8874            model: Some("gpt-main".to_string()),
8875            fast_model: Some("gpt-fast".to_string()),
8876            vision_model: None,
8877            reasoning_effort: None,
8878            responses_only_models: vec![],
8879            request_overrides: None,
8880            extra: BTreeMap::new(),
8881            api_key_from_env: false,
8882        });
8883
8884        assert_eq!(
8885            config.get_memory_background_model().as_deref(),
8886            Some("gpt-fast")
8887        );
8888    }
8889
8890    #[test]
8891    fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8892        let mut config = Config::default();
8893        config.features.provider_model_ref = false;
8894        config.provider = "openai".to_string();
8895        config.providers.openai = Some(OpenAIConfig {
8896            api_key: "sk-stale".to_string(),
8897            model: Some("legacy-main".to_string()),
8898            fast_model: Some("legacy-fast".to_string()),
8899            vision_model: Some("legacy-vision".to_string()),
8900            reasoning_effort: Some(ReasoningEffort::Low),
8901            ..OpenAIConfig::default()
8902        });
8903        let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8904            "provider_type": "openai",
8905            "model": "instance-main",
8906            "fast_model": "instance-fast",
8907            "vision_model": "instance-vision",
8908            "reasoning_effort": "high",
8909            "enabled": true
8910        }))
8911        .unwrap();
8912        instance.api_key = "sk-instance".to_string();
8913        config
8914            .provider_instances
8915            .insert("work".to_string(), instance);
8916        config.default_provider_instance = Some("work".to_string());
8917
8918        assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8919        assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8920        assert_eq!(
8921            config.get_memory_background_model().as_deref(),
8922            Some("instance-fast")
8923        );
8924        assert_eq!(
8925            config.get_task_summary_model().as_deref(),
8926            Some("instance-fast")
8927        );
8928        assert_eq!(
8929            config.get_vision_model().as_deref(),
8930            Some("instance-vision")
8931        );
8932        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8933    }
8934
8935    #[test]
8936    fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8937        let mut config = Config::default();
8938        config.features.provider_model_ref = true;
8939        config.provider_instances.insert(
8940            "compatibility-default".to_string(),
8941            serde_json::from_value(serde_json::json!({
8942                "provider_type": "openai",
8943                "reasoning_effort": "low",
8944                "enabled": true
8945            }))
8946            .unwrap(),
8947        );
8948        config.provider_instances.insert(
8949            "chat-provider".to_string(),
8950            serde_json::from_value(serde_json::json!({
8951                "provider_type": "anthropic",
8952                "reasoning_effort": "high",
8953                "enabled": true
8954            }))
8955            .unwrap(),
8956        );
8957        config.default_provider_instance = Some("compatibility-default".to_string());
8958        config.defaults = Some(
8959            serde_json::from_value(serde_json::json!({
8960                "chat": { "provider": "chat-provider", "model": "claude" }
8961            }))
8962            .unwrap(),
8963        );
8964
8965        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8966
8967        config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8968        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8969    }
8970
8971    #[test]
8972    fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8973        let _provider =
8974            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8975        let _openai_key = crate::test_support::override_runtime_env_var(
8976            "BAMBOO_OPENAI_API_KEY",
8977            Some("sk-runtime-env-instance"),
8978        );
8979        let _anthropic_key =
8980            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8981        let _gemini_key =
8982            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8983
8984        let mut config = Config::default();
8985        *config.providers_mut() = ProviderConfigs::default();
8986        for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8987            let mut extra = serde_json::Map::new();
8988            if from_environment {
8989                extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8990            }
8991            extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8992            extra.insert("enabled".to_string(), serde_json::json!(true));
8993            config.provider_instances.insert(
8994                id.to_string(),
8995                serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8996            );
8997        }
8998
8999        config.apply_runtime_env_overrides();
9000
9001        assert_eq!(
9002            config.default_provider_instance.as_deref(),
9003            Some("a-marked"),
9004            "type override selects the lexicographically first enabled instance"
9005        );
9006        assert_eq!(
9007            config.provider_instances["a-marked"].api_key,
9008            "sk-runtime-env-instance"
9009        );
9010        assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
9011        assert!(
9012            config.providers().openai.is_none(),
9013            "instance-mode env hydration must not recreate a legacy alias"
9014        );
9015
9016        config.refresh_encrypted_secrets().unwrap();
9017        let serialized = serde_json::to_string(&config).unwrap();
9018        assert!(!serialized.contains("sk-runtime-env-instance"));
9019        assert!(
9020            config.provider_instances["a-marked"]
9021                .api_key_encrypted
9022                .is_none(),
9023            "runtime env plaintext must not be encrypted into ordinary config"
9024        );
9025    }
9026
9027    #[test]
9028    fn runtime_provider_override_prefers_exact_instance_id() {
9029        let _provider =
9030            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
9031        let _openai_key =
9032            crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
9033        let _anthropic_key =
9034            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
9035        let _gemini_key =
9036            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9037        let mut config = Config::default();
9038        for id in ["work", "personal"] {
9039            config.provider_instances.insert(
9040                id.to_string(),
9041                serde_json::from_value(serde_json::json!({
9042                    "provider_type": "openai",
9043                    "enabled": true
9044                }))
9045                .unwrap(),
9046            );
9047        }
9048
9049        config.apply_runtime_env_overrides();
9050        assert_eq!(
9051            config.default_provider_instance.as_deref(),
9052            Some("personal")
9053        );
9054    }
9055
9056    #[test]
9057    fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
9058        let mut config = Config::default();
9059        config.features.provider_model_ref = false;
9060        config.provider = "openai".to_string();
9061        config.providers.openai = Some(OpenAIConfig {
9062            api_key: "test".to_string(),
9063            api_key_encrypted: None,
9064            credential_ref: None,
9065            base_url: None,
9066            model: Some("gpt-main".to_string()),
9067            fast_model: None,
9068            vision_model: None,
9069            reasoning_effort: None,
9070            responses_only_models: vec![],
9071            request_overrides: None,
9072            extra: BTreeMap::new(),
9073            api_key_from_env: false,
9074        });
9075
9076        assert_eq!(
9077            config.get_memory_background_model().as_deref(),
9078            Some("gpt-main")
9079        );
9080    }
9081
9082    #[test]
9083    fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
9084        let legacy = serde_json::json!({
9085            "memory": MemoryConfig {
9086                background_model: Some("dream-fast".to_string()),
9087                summary_target_ratio: 0.20,
9088                summary_safe_window_percent: 80,
9089                auto_dream_enabled: true,
9090                auto_dream_interval_secs: 900,
9091                project_prompt_injection: false,
9092                relevant_recall: false,
9093                relevant_recall_rerank: true,
9094                project_first_dream: false,
9095                ledger_agenda_injection: false,
9096                ledger_gardener_enabled: false,
9097                ledger_gardener_interval_secs: 7_200,
9098                ledger_distillation_enabled: false,
9099                dream_refine_mode: true,
9100                gardener_enabled: true,
9101                gardener_interval_secs: 3_600,
9102                gardener_volume_trigger: 40,
9103                gardener_max_splits_per_run: 4,
9104                gardener_min_sections: 7,
9105                dedup_gardener_enabled: true,
9106                dedup_gardener_min_score: 0.7,
9107                dedup_gardener_max_merges_per_run: 3,
9108                memory_active_capacity: 500,
9109                capacity_max_archivals_per_run: 10,
9110                granularity_freshness_gardener_enabled: false,
9111            }
9112        });
9113        let config: Config = serde_json::from_value(legacy).unwrap();
9114
9115        let serialized = serde_json::to_value(&config).expect("config should serialize");
9116        assert!(serialized.get("memory").is_some());
9117        let round_tripped: Config = serde_json::from_value(serialized).unwrap();
9118        assert!(round_tripped
9119            .memory()
9120            .as_ref()
9121            .is_some_and(|memory| memory.dream_refine_mode));
9122        let memory = config.memory.as_ref().expect("memory config should exist");
9123        assert!(memory.auto_dream_enabled);
9124        assert!(!memory.project_prompt_injection);
9125        assert!(!memory.relevant_recall);
9126        assert!(memory.relevant_recall_rerank);
9127        assert!(!memory.project_first_dream);
9128        assert!(memory.dream_refine_mode);
9129        assert!(memory.gardener_enabled);
9130        assert_eq!(memory.gardener_interval_secs, 3_600);
9131        assert_eq!(memory.gardener_volume_trigger, 40);
9132        assert_eq!(memory.gardener_max_splits_per_run, 4);
9133        assert_eq!(memory.gardener_min_sections, 7);
9134        assert!(memory.dedup_gardener_enabled);
9135        assert_eq!(memory.dedup_gardener_min_score, 0.7);
9136        assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
9137        assert_eq!(memory.memory_active_capacity, 500);
9138        assert_eq!(memory.capacity_max_archivals_per_run, 10);
9139        assert!(!memory.granularity_freshness_gardener_enabled);
9140    }
9141
9142    /// L5: capacity is OFF by default (0 = unbounded) — an opt-in feature.
9143    #[test]
9144    fn memory_active_capacity_defaults_off() {
9145        assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
9146        assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
9147        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9148        let memory = parsed.memory.as_ref().unwrap();
9149        assert_eq!(memory.memory_active_capacity, 0);
9150        assert_eq!(
9151            memory.capacity_max_archivals_per_run, 50,
9152            "omitted field takes the serde default fn"
9153        );
9154    }
9155
9156    #[test]
9157    fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
9158        let defaults = MemoryConfig::default();
9159        assert_eq!(defaults.summary_target_ratio, 0.20);
9160        assert_eq!(defaults.summary_safe_window_percent, 80);
9161
9162        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9163        let memory = parsed.memory.as_ref().expect("memory present");
9164        assert_eq!(memory.summary_target_ratio, 0.20);
9165        assert_eq!(memory.summary_safe_window_percent, 80);
9166    }
9167
9168    /// L4: the maintenance integrators are ON by default — both via
9169    /// `MemoryConfig::default()` AND when a config file omits the flags entirely
9170    /// (serde `default = fn`, not the bare `#[serde(default)]` = `false`).
9171    #[test]
9172    fn memory_maintenance_integrators_default_on() {
9173        let defaults = MemoryConfig::default();
9174        assert!(defaults.auto_dream_enabled);
9175        assert!(defaults.gardener_enabled);
9176        assert!(defaults.dedup_gardener_enabled);
9177        assert_eq!(defaults.gardener_volume_trigger, 25);
9178
9179        // A config that mentions `memory` but omits the flags must still be ON.
9180        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9181        let memory = parsed.memory.as_ref().expect("memory present");
9182        assert!(
9183            memory.auto_dream_enabled,
9184            "auto_dream on when field omitted"
9185        );
9186        assert!(memory.gardener_enabled, "gardener on when field omitted");
9187        assert!(
9188            memory.dedup_gardener_enabled,
9189            "dedup gardener on when field omitted"
9190        );
9191        // An explicit opt-out is still honored.
9192        let opted_out: Config =
9193            serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
9194        assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
9195    }
9196
9197    #[test]
9198    fn memory_config_env_overrides_prompt_flags() {
9199        let _lock = env_lock_acquire();
9200        let temp_home = TempHome::new();
9201        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9202        let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
9203        let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
9204        let _relevant_recall_rerank =
9205            EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
9206        let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
9207
9208        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9209        let memory = config
9210            .memory
9211            .as_ref()
9212            .expect("memory config should be created by env overrides");
9213        assert!(!memory.project_prompt_injection);
9214        assert!(!memory.relevant_recall);
9215        assert!(memory.relevant_recall_rerank);
9216        assert!(!memory.project_first_dream);
9217    }
9218
9219    #[test]
9220    fn provider_api_keys_injected_from_env_and_never_persisted() {
9221        let _lock = env_lock_acquire();
9222        let temp_home = TempHome::new();
9223        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9224        let _anthropic = crate::test_support::override_runtime_env_var(
9225            "BAMBOO_ANTHROPIC_API_KEY",
9226            Some("sk-ant-from-env"),
9227        );
9228        let _openai = crate::test_support::override_runtime_env_var(
9229            "BAMBOO_OPENAI_API_KEY",
9230            Some("sk-oai-from-env"),
9231        );
9232        let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9233
9234        // No config.json on disk → the providers are created from the env keys
9235        // alone (#253: deploy without a plaintext api_key in a mounted file).
9236        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9237        assert_eq!(
9238            config
9239                .providers
9240                .anthropic
9241                .as_ref()
9242                .expect("anthropic created from env")
9243                .api_key,
9244            "sk-ant-from-env"
9245        );
9246        assert_eq!(
9247            config
9248                .providers
9249                .openai
9250                .as_ref()
9251                .expect("openai created from env")
9252                .api_key,
9253            "sk-oai-from-env"
9254        );
9255        // An unset provider is not fabricated.
9256        assert!(config.providers.gemini.is_none());
9257
9258        // The real "never persisted" guarantee: saving the config must NOT bake
9259        // the env key into config.json — not as plaintext AND not re-encrypted
9260        // into `api_key_encrypted` (which save's `refresh_provider_api_keys_encrypted`
9261        // would otherwise do). This is what actually happens on the server when
9262        // any unrelated setting is saved / on a fabric-reconcile boot.
9263        config
9264            .save_to_dir(temp_home.path.clone())
9265            .expect("save config");
9266        let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
9267            .expect("read persisted config.json");
9268        assert!(
9269            !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
9270            "env key must not be persisted as plaintext"
9271        );
9272        let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
9273        assert!(
9274            disk_json["providers"]["anthropic"]
9275                .get("api_key_encrypted")
9276                .is_none(),
9277            "env-sourced anthropic key must not be re-encrypted into config.json"
9278        );
9279        assert!(
9280            disk_json["providers"]["openai"]
9281                .get("api_key_encrypted")
9282                .is_none(),
9283            "env-sourced openai key must not be re-encrypted into config.json"
9284        );
9285
9286        // And once the env vars are gone, a reload from that same dir has no key
9287        // (nothing was persisted).
9288        drop(_anthropic);
9289        drop(_openai);
9290        let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
9291        assert!(reloaded
9292            .providers
9293            .anthropic
9294            .as_ref()
9295            .map(|a| a.api_key.is_empty())
9296            .unwrap_or(true));
9297    }
9298
9299    #[test]
9300    fn get_default_work_area_path_expands_tilde_and_requires_directory() {
9301        let _lock = env_lock_acquire();
9302        let temp_home = TempHome::new();
9303        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9304        let target = temp_home.path.join("workspace-default");
9305        std::fs::create_dir_all(&target).expect("default work area dir should exist");
9306
9307        let mut config = Config::default();
9308        config.default_work_area.replace(DefaultWorkAreaConfig {
9309            path: Some("~/workspace-default".to_string()),
9310        });
9311
9312        assert_eq!(config.get_default_work_area_path(), Some(target));
9313    }
9314
9315    #[test]
9316    fn get_default_work_area_path_returns_none_for_missing_directory() {
9317        let _lock = env_lock_acquire();
9318        let temp_home = TempHome::new();
9319        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9320
9321        let mut config = Config::default();
9322        config.default_work_area.replace(DefaultWorkAreaConfig {
9323            path: Some("~/missing-default-work-area".to_string()),
9324        });
9325
9326        assert!(config.get_default_work_area_path().is_none());
9327    }
9328
9329    #[test]
9330    fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9331        let mut config = Config::default();
9332        config.tools.disabled = vec![
9333            "  read_file  ".to_string(),
9334            "".to_string(),
9335            "read_file".to_string(),
9336            "bash".to_string(),
9337            "default::getCurrentDir".to_string(),
9338            "default::applyPatch".to_string(),
9339            "default::custom_tool".to_string(),
9340            "mcp__alpha__inspect".to_string(),
9341        ];
9342
9343        config.normalize_tool_settings();
9344
9345        assert_eq!(
9346            config.tools.disabled,
9347            vec![
9348                "bash",
9349                "default::applyPatch",
9350                "default::custom_tool",
9351                "default::getCurrentDir",
9352                "mcp__alpha__inspect",
9353                "read_file"
9354            ]
9355        );
9356    }
9357
9358    #[test]
9359    fn config_load_preserves_disabled_references_for_catalog_resolution() {
9360        let _lock = env_lock_acquire();
9361        let temp_home = TempHome::new();
9362        temp_home.set_config_json(
9363            r#"{
9364  "tools": {
9365    "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9366  }
9367}"#,
9368        );
9369
9370        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9371        assert_eq!(
9372            config.tools.disabled,
9373            vec!["bash", "default::getCurrentDir", "read_file"]
9374        );
9375        assert!(config.disabled_tool_references().contains("bash"));
9376        assert!(config.disabled_tool_references().contains("read_file"));
9377        assert!(config
9378            .disabled_tool_references()
9379            .contains("default::getCurrentDir"));
9380        assert_eq!(
9381            config.disabled_tool_names(),
9382            BTreeSet::from([
9383                "Bash".to_string(),
9384                "GetCurrentDir".to_string(),
9385                "Read".to_string()
9386            ])
9387        );
9388    }
9389
9390    #[test]
9391    fn normalize_skill_settings_trims_dedupes_and_sorts() {
9392        let mut config = Config::default();
9393        config.skills.disabled = vec![
9394            " pdf ".to_string(),
9395            "".to_string(),
9396            "pdf".to_string(),
9397            "skill-creator".to_string(),
9398        ];
9399
9400        config.normalize_skill_settings();
9401
9402        assert_eq!(
9403            config.skills.disabled,
9404            vec!["pdf".to_string(), "skill-creator".to_string()]
9405        );
9406    }
9407
9408    #[test]
9409    fn config_load_reads_disabled_skills_as_normalized_ids() {
9410        let _lock = env_lock_acquire();
9411        let temp_home = TempHome::new();
9412        temp_home.set_config_json(
9413            r#"{
9414  "skills": {
9415    "disabled": [" pdf ", "skill-creator", "pdf", ""]
9416  }
9417}"#,
9418        );
9419
9420        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9421        assert_eq!(
9422            config.skills.disabled,
9423            vec!["pdf".to_string(), "skill-creator".to_string()]
9424        );
9425        assert!(config.disabled_skill_ids().contains("pdf"));
9426        assert!(config.disabled_skill_ids().contains("skill-creator"));
9427    }
9428
9429    #[test]
9430    fn test_server_config_defaults() {
9431        let _lock = env_lock_acquire();
9432        let temp_home = TempHome::new();
9433
9434        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9435        assert_eq!(config.server.port, 9562);
9436        assert_eq!(config.server.bind, "127.0.0.1");
9437        assert_eq!(config.server.workers, 10);
9438        assert!(config.server.static_dir.is_none());
9439    }
9440
9441    #[test]
9442    fn test_server_addr() {
9443        let mut config = Config::default();
9444        config.server.port = 9000;
9445        config.server.bind = "0.0.0.0".to_string();
9446        assert_eq!(config.server_addr(), "0.0.0.0:9000");
9447    }
9448
9449    #[test]
9450    fn test_env_var_overrides() {
9451        let _lock = env_lock_acquire();
9452        let temp_home = TempHome::new();
9453
9454        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9455        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9456        let _provider =
9457            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9458
9459        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9460        assert_eq!(config.server.port, 9999);
9461        assert_eq!(config.server.bind, "192.168.1.1");
9462        assert_eq!(config.provider, "openai");
9463    }
9464
9465    #[test]
9466    fn test_config_save_and_load() {
9467        let _lock = env_lock_acquire();
9468        let temp_home = TempHome::new();
9469
9470        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9471        config.server.port = 9000;
9472        config.server.bind = "0.0.0.0".to_string();
9473        config.provider = "anthropic".to_string();
9474
9475        // Save
9476        config
9477            .save_to_dir(temp_home.path.clone())
9478            .expect("Failed to save config");
9479
9480        // Load again
9481        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9482
9483        // Verify
9484        assert_eq!(loaded.server.port, 9000);
9485        assert_eq!(loaded.server.bind, "0.0.0.0");
9486        assert_eq!(loaded.provider, "anthropic");
9487    }
9488
9489    #[test]
9490    fn modular_root_and_public_serde_preserve_legacy_shape() {
9491        let _lock = env_lock_acquire();
9492        let temp_home = TempHome::new();
9493        let input = serde_json::json!({
9494            "http_proxy": "http://proxy.example",
9495            "provider": "openai",
9496            "mcp": { "servers": [] },
9497            "future_extension": { "enabled": true },
9498            "memory": { "background_model": "memory-model" },
9499            "subagents": { "max_concurrent": 7 },
9500            "providers": { "openai": { "model": "chat-model" } }
9501        });
9502
9503        let config: Config = serde_json::from_value(input).unwrap();
9504        let public = serde_json::to_value(&config).unwrap();
9505        assert_eq!(public["http_proxy"], "http://proxy.example");
9506        assert!(public.get("mcp").is_none());
9507        assert!(public.get("mcpServers").is_some());
9508        assert_eq!(public["future_extension"]["enabled"], true);
9509        assert_eq!(public["memory"]["background_model"], "memory-model");
9510        assert_eq!(public["subagents"]["max_concurrent"], 7);
9511        assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9512
9513        let round_tripped: Config = serde_json::from_value(public).unwrap();
9514        assert_eq!(
9515            round_tripped
9516                .memory()
9517                .as_ref()
9518                .unwrap()
9519                .background_model
9520                .as_deref(),
9521            Some("memory-model")
9522        );
9523        assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9524        assert_eq!(
9525            round_tripped
9526                .providers()
9527                .openai
9528                .as_ref()
9529                .unwrap()
9530                .model
9531                .as_deref(),
9532            Some("chat-model")
9533        );
9534        assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9535
9536        round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9537        let persisted: Value =
9538            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9539                .unwrap();
9540        assert_eq!(persisted["http_proxy"], "http://proxy.example");
9541        assert!(persisted.get("mcpServers").is_some());
9542        assert_eq!(persisted["future_extension"]["enabled"], true);
9543        assert!(persisted.get("memory").is_none());
9544        assert!(persisted.get("subagents").is_none());
9545        assert!(persisted.get("providers").is_none());
9546        for internal_section_name in [
9547            "network",
9548            "provider_routing",
9549            "model_behavior",
9550            "tooling",
9551            "workspace",
9552            "execution",
9553            "integrations",
9554            "plugin_security",
9555        ] {
9556            assert!(persisted.get(internal_section_name).is_none());
9557        }
9558    }
9559
9560    #[test]
9561    fn config_decrypts_proxy_auth_from_encrypted_field() {
9562        let _lock = env_lock_acquire();
9563        let temp_home = TempHome::new();
9564
9565        // Use a stable encryption key so this test doesn't depend on host identifiers.
9566        let key_guard = crate::encryption::set_test_encryption_key([
9567            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9568            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9569            0x1c, 0x1d, 0x1e, 0x1f,
9570        ]);
9571
9572        let auth = ProxyAuth {
9573            username: "user".to_string(),
9574            password: "pass".to_string(),
9575        };
9576        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9577        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9578
9579        temp_home.set_config_json(&format!(
9580            r#"{{
9581  "http_proxy": "http://proxy.example.com:8080",
9582  "proxy_auth_encrypted": "{encrypted}"
9583}}"#
9584        ));
9585        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9586        let loaded_auth = config
9587            .proxy_auth
9588            .as_ref()
9589            .expect("proxy auth should be hydrated");
9590        assert_eq!(loaded_auth.username, "user");
9591        assert_eq!(loaded_auth.password, "pass");
9592        drop(key_guard);
9593    }
9594
9595    #[test]
9596    fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9597        let _lock = env_lock_acquire();
9598        let temp_home = TempHome::new();
9599
9600        // Use a stable encryption key so this test doesn't depend on host identifiers.
9601        let key_guard = crate::encryption::set_test_encryption_key([
9602            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9603            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9604            0x1c, 0x1d, 0x1e, 0x1f,
9605        ]);
9606
9607        let auth = ProxyAuth {
9608            username: "user".to_string(),
9609            password: "pass".to_string(),
9610        };
9611        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9612        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9613
9614        // Simulate older Bodhi/Tauri persisted config keys.
9615        temp_home.set_config_json(&format!(
9616            r#"{{
9617  "http_proxy": "http://proxy.example.com:8080",
9618  "http_proxy_auth_encrypted": "{encrypted}",
9619  "https_proxy_auth_encrypted": "{encrypted}"
9620}}"#
9621        ));
9622
9623        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9624        let loaded_auth = config
9625            .proxy_auth
9626            .as_ref()
9627            .expect("proxy auth should be hydrated");
9628        assert_eq!(loaded_auth.username, "user");
9629        assert_eq!(loaded_auth.password, "pass");
9630        drop(key_guard);
9631    }
9632
9633    #[test]
9634    fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9635        let _lock = env_lock_acquire();
9636        let temp_home = TempHome::new();
9637
9638        // Use a stable encryption key so this test doesn't depend on host identifiers.
9639        let key_guard = crate::encryption::set_test_encryption_key([
9640            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9641            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9642            0x1c, 0x1d, 0x1e, 0x1f,
9643        ]);
9644
9645        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9646        config.proxy_auth = Some(ProxyAuth {
9647            username: "user".to_string(),
9648            password: "pass".to_string(),
9649        });
9650        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9651        assert!(error
9652            .to_string()
9653            .contains("isolated credential transaction"));
9654        let path = temp_home.path.join("config.json");
9655        assert!(
9656            !path.exists(),
9657            "a rejected unisolated secret must not create config.json"
9658        );
9659        drop(key_guard);
9660    }
9661
9662    #[test]
9663    fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9664        let _lock = env_lock_acquire();
9665        let temp_home = TempHome::new();
9666        let mut config = Config::default();
9667        config.env_vars.push(EnvVarEntry {
9668            name: "TOKEN".to_string(),
9669            value: String::new(),
9670            secret: true,
9671            value_encrypted: None,
9672            credential_ref: None,
9673            configured: true,
9674            description: None,
9675        });
9676        let path = temp_home.path.join("config.json");
9677
9678        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9679        assert!(error
9680            .to_string()
9681            .contains("isolated credential transaction"));
9682        assert!(
9683            !path.exists(),
9684            "a rejected dangling ref must not create config.json"
9685        );
9686
9687        let original = br#"{"preserve":"original"}"#;
9688        std::fs::write(&path, original).unwrap();
9689        config.save_to_dir(temp_home.path.clone()).unwrap_err();
9690        assert_eq!(
9691            std::fs::read(&path).unwrap(),
9692            original,
9693            "a rejected dangling ref must not modify an existing config.json"
9694        );
9695    }
9696
9697    #[test]
9698    fn config_save_persists_provider_reference_and_isolates_plaintext() {
9699        let _lock = env_lock_acquire();
9700        let temp_home = TempHome::new();
9701
9702        // Use a stable encryption key so this test doesn't depend on host identifiers.
9703        let key_guard = crate::encryption::set_test_encryption_key([
9704            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9705            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9706            0x1c, 0x1d, 0x1e, 0x1f,
9707        ]);
9708
9709        let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9710        crate::CredentialStore::open(&temp_home.path)
9711            .replace(
9712                reference.clone(),
9713                "sk-test-provider-key",
9714                crate::CredentialSource::User,
9715                0,
9716            )
9717            .unwrap();
9718        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9719        config.provider = "openai".to_string();
9720        config.providers.openai = Some(OpenAIConfig {
9721            api_key: "sk-test-provider-key".to_string(),
9722            api_key_encrypted: None,
9723            credential_ref: Some(reference),
9724            base_url: None,
9725            model: None,
9726            fast_model: None,
9727            vision_model: None,
9728            reasoning_effort: None,
9729            responses_only_models: vec![],
9730            request_overrides: None,
9731            extra: Default::default(),
9732            api_key_from_env: false,
9733        });
9734
9735        config
9736            .save_to_dir(temp_home.path.clone())
9737            .expect("save should persist provider reference");
9738
9739        let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9740            .expect("read providers.json");
9741        assert!(
9742            !content.contains("api_key_encrypted"),
9743            "providers.json must not store provider ciphertext"
9744        );
9745        assert!(
9746            !content.contains("\"api_key\""),
9747            "providers.json should not store plaintext provider keys"
9748        );
9749
9750        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9751        let openai = loaded
9752            .providers
9753            .openai
9754            .as_ref()
9755            .expect("openai config should be present");
9756        assert_eq!(openai.api_key, "sk-test-provider-key");
9757
9758        drop(key_guard);
9759    }
9760
9761    #[test]
9762    fn config_save_persists_mcp_servers_in_mainstream_format() {
9763        let _lock = env_lock_acquire();
9764        let temp_home = TempHome::new();
9765
9766        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9767
9768        let mut env = std::collections::HashMap::new();
9769        env.insert("TOKEN".to_string(), "supersecret".to_string());
9770
9771        config.mcp.servers = vec![
9772            bamboo_domain::mcp_config::McpServerConfig {
9773                id: "stdio-secret".to_string(),
9774                name: None,
9775                enabled: true,
9776                transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9777                    bamboo_domain::mcp_config::StdioConfig {
9778                        command: "echo".to_string(),
9779                        args: vec![],
9780                        cwd: None,
9781                        env,
9782                        env_encrypted: std::collections::HashMap::new(),
9783                        env_credential_refs: std::collections::HashMap::new(),
9784                        startup_timeout_ms: 5000,
9785                    },
9786                ),
9787                request_timeout_ms: 5000,
9788                healthcheck_interval_ms: 1000,
9789                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9790                allowed_tools: vec![],
9791                denied_tools: vec![],
9792            },
9793            bamboo_domain::mcp_config::McpServerConfig {
9794                id: "http-secret".to_string(),
9795                name: None,
9796                enabled: true,
9797                transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9798                    bamboo_domain::mcp_config::StreamableHttpConfig {
9799                        url: "http://localhost:8080/http".to_string(),
9800                        headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9801                            name: "Authorization".to_string(),
9802                            value: "Bearer token123".to_string(),
9803                            value_encrypted: None,
9804                            credential_ref: None,
9805                        }],
9806                        connect_timeout_ms: 5000,
9807                    },
9808                ),
9809                request_timeout_ms: 5000,
9810                healthcheck_interval_ms: 1000,
9811                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9812                allowed_tools: vec![],
9813                denied_tools: vec![],
9814            },
9815        ];
9816
9817        config
9818            .save_to_dir(temp_home.path.clone())
9819            .expect("save should persist MCP servers");
9820
9821        let content =
9822            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9823        assert!(
9824            content.contains("\"mcpServers\""),
9825            "config.json should store MCP servers under the mainstream 'mcpServers' key"
9826        );
9827        assert!(
9828            content.contains("supersecret"),
9829            "config.json should persist MCP stdio env in mainstream format"
9830        );
9831        assert!(
9832            content.contains("Bearer token123"),
9833            "config.json should persist MCP Streamable HTTP headers in mainstream format"
9834        );
9835        assert!(
9836            !content.contains("\"env_encrypted\""),
9837            "config.json should not persist legacy env_encrypted fields"
9838        );
9839        assert!(
9840            !content.contains("\"value_encrypted\""),
9841            "config.json should not persist legacy value_encrypted fields"
9842        );
9843
9844        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9845        let stdio = loaded
9846            .mcp
9847            .servers
9848            .iter()
9849            .find(|s| s.id == "stdio-secret")
9850            .expect("stdio server should exist");
9851        match &stdio.transport {
9852            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9853                assert_eq!(
9854                    stdio.env.get("TOKEN").map(|s| s.as_str()),
9855                    Some("supersecret")
9856                );
9857            }
9858            _ => panic!("Expected stdio transport"),
9859        }
9860
9861        let http = loaded
9862            .mcp
9863            .servers
9864            .iter()
9865            .find(|s| s.id == "http-secret")
9866            .expect("http server should exist");
9867        match &http.transport {
9868            bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9869                assert_eq!(http.headers[0].value, "Bearer token123");
9870            }
9871            _ => panic!("Expected Streamable HTTP transport"),
9872        }
9873    }
9874
9875    #[test]
9876    fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9877        let _lock = env_lock_acquire();
9878        let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9879        let temp_home = TempHome::new();
9880        let env_plaintext = "mcp-root-env-plaintext-597";
9881        let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9882        let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9883        std::fs::write(
9884            temp_home.path.join("config.json"),
9885            serde_json::to_vec_pretty(&serde_json::json!({
9886                "features": {"provider_model_ref": true},
9887                "mcpServers": {
9888                    "stdio-root": {
9889                        "command": "unused-disabled-command",
9890                        "env": {"TOKEN": env_plaintext},
9891                        "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9892                        "env_credential_refs": {
9893                            "TOKEN": "mcp.stdio-root.env_TOKEN"
9894                        }
9895                    },
9896                    "http-root": {
9897                        "url": "https://example.test/mcp",
9898                        "transport_kind": "streamable_http",
9899                        "headers": {"Authorization": header_plaintext},
9900                        "header_credential_refs": {
9901                            "Authorization": "mcp.http-root.header_Authorization"
9902                        }
9903                    }
9904                }
9905            }))
9906            .unwrap(),
9907        )
9908        .unwrap();
9909        std::fs::write(
9910            temp_home.path.join("mcp.json"),
9911            serde_json::to_vec_pretty(&serde_json::json!({
9912                "schema_version": 1,
9913                "revision": 7,
9914                "data": {
9915                    "stdio-root": {
9916                        "command": "unused-disabled-command",
9917                        "enabled": false,
9918                        "env_encrypted": {"TOKEN": legacy_ciphertext},
9919                        "request_timeout_ms": 100,
9920                        "healthcheck_interval_ms": 100
9921                    },
9922                    "http-root": {
9923                        "url": "https://example.test/mcp",
9924                        "transport_kind": "streamable_http",
9925                        "enabled": false,
9926                        "headers": {"Authorization": header_plaintext},
9927                        "request_timeout_ms": 100,
9928                        "healthcheck_interval_ms": 100
9929                    }
9930                }
9931            }))
9932            .unwrap(),
9933        )
9934        .unwrap();
9935
9936        crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9937        let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9938            temp_home.path.join("mcp.json"),
9939            1,
9940        )
9941        .load()
9942        .unwrap()
9943        .unwrap();
9944        let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9945        config.mcp = stored.data;
9946        config
9947            .hydrate_mcp_credentials_from_store(&temp_home.path)
9948            .unwrap();
9949
9950        let public = serde_json::to_value(&config.mcp).unwrap();
9951        let rendered_public = public.to_string();
9952        assert!(rendered_public.contains(env_plaintext));
9953        assert!(rendered_public.contains(header_plaintext));
9954        let compatible: bamboo_domain::mcp_config::McpConfig =
9955            serde_json::from_value(public).unwrap();
9956        assert_eq!(compatible.servers.len(), 2);
9957
9958        config.features.provider_model_ref = !config.features.provider_model_ref;
9959        config.save_to_dir(temp_home.path.clone()).unwrap();
9960        config.features.provider_model_ref = !config.features.provider_model_ref;
9961        config.save_to_dir(temp_home.path.clone()).unwrap();
9962
9963        for entry in std::fs::read_dir(&temp_home.path)
9964            .unwrap()
9965            .filter_map(Result::ok)
9966        {
9967            let name = entry.file_name().to_string_lossy().to_string();
9968            if name == "credentials.json" || entry.path().is_dir() {
9969                continue;
9970            }
9971            if name == "config.json"
9972                || name == "mcp.json"
9973                || name.contains(".bak")
9974                || name.starts_with("config-credential-migration")
9975            {
9976                let bytes = std::fs::read(entry.path()).unwrap();
9977                let content = String::from_utf8_lossy(&bytes);
9978                assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9979                assert!(
9980                    !content.contains(header_plaintext),
9981                    "secret leaked to {name}"
9982                );
9983                assert!(
9984                    !content.contains(&legacy_ciphertext),
9985                    "legacy ciphertext leaked to {name}"
9986                );
9987            }
9988        }
9989    }
9990
9991    // ── Env vars lifecycle tests ──────────────────────────────
9992
9993    #[test]
9994    fn env_vars_as_map_includes_only_non_empty_values() {
9995        let mut config = Config::default();
9996        config.env_vars.extend([
9997            EnvVarEntry {
9998                name: "A".to_string(),
9999                value: "val_a".to_string(),
10000                secret: false,
10001                value_encrypted: None,
10002                credential_ref: None,
10003                configured: true,
10004                description: None,
10005            },
10006            EnvVarEntry {
10007                name: "B".to_string(),
10008                value: "".to_string(), // empty → should be excluded
10009                secret: true,
10010                value_encrypted: None,
10011                credential_ref: None,
10012                configured: false,
10013                description: None,
10014            },
10015            EnvVarEntry {
10016                name: "C".to_string(),
10017                value: "  ".to_string(), // whitespace-only → excluded
10018                secret: false,
10019                value_encrypted: None,
10020                credential_ref: None,
10021                configured: true,
10022                description: None,
10023            },
10024            EnvVarEntry {
10025                name: "D".to_string(),
10026                value: "val_d".to_string(),
10027                secret: true,
10028                value_encrypted: Some("enc".to_string()),
10029                credential_ref: None,
10030                configured: true,
10031                description: Some("desc".to_string()),
10032            },
10033        ]);
10034
10035        let map = config.env_vars_as_map();
10036        assert_eq!(map.len(), 2);
10037        assert_eq!(map.get("A"), Some(&"val_a".to_string()));
10038        assert_eq!(map.get("D"), Some(&"val_d".to_string()));
10039        assert!(!map.contains_key("B"));
10040        assert!(!map.contains_key("C"));
10041    }
10042
10043    #[test]
10044    fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
10045        let mut config = Config::default();
10046        config.env_vars.extend([
10047            EnvVarEntry {
10048                name: "PLAIN".to_string(),
10049                value: "visible".to_string(),
10050                secret: false,
10051                value_encrypted: None,
10052                credential_ref: None,
10053                configured: true,
10054                description: None,
10055            },
10056            EnvVarEntry {
10057                name: "SECRET".to_string(),
10058                value: "hidden_value".to_string(),
10059                secret: true,
10060                value_encrypted: Some("enc_data".to_string()),
10061                credential_ref: None,
10062                configured: true,
10063                description: None,
10064            },
10065        ]);
10066
10067        config.sanitize_env_vars_for_disk();
10068
10069        assert_eq!(config.env_vars[0].value, "visible"); // plain kept
10070        assert_eq!(config.env_vars[1].value, ""); // secret cleared
10071    }
10072
10073    #[test]
10074    fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
10075        let mut config = Config::default();
10076        config.env_vars.extend([
10077            EnvVarEntry {
10078                name: "OPEN".to_string(),
10079                value: "val".to_string(),
10080                secret: false,
10081                value_encrypted: None,
10082                credential_ref: None,
10083                configured: true,
10084                description: None,
10085            },
10086            EnvVarEntry {
10087                name: "HIDDEN".to_string(),
10088                value: "real_secret".to_string(),
10089                secret: true,
10090                value_encrypted: Some("enc".to_string()),
10091                credential_ref: None,
10092                configured: true,
10093                description: None,
10094            },
10095        ]);
10096
10097        config.sanitize_env_vars_for_disk();
10098
10099        // Plain value untouched
10100        assert_eq!(config.env_vars[0].value, "val");
10101        // Secret plaintext and legacy ciphertext are removed.
10102        assert_eq!(config.env_vars[1].value, "");
10103        assert!(config.env_vars[1].value_encrypted.is_none());
10104    }
10105
10106    #[test]
10107    fn legacy_env_ciphertext_is_read_but_never_serialized() {
10108        let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
10109        let mut config: Config = serde_json::from_value(serde_json::json!({
10110            "env_vars": [{
10111                "name": "TOKEN",
10112                "secret": true,
10113                "value_encrypted": ciphertext
10114            }]
10115        }))
10116        .unwrap();
10117        config.hydrate_env_vars_from_encrypted();
10118        assert_eq!(config.env_vars[0].value, "my-secret-token");
10119        let serialized = serde_json::to_value(&config).unwrap();
10120        assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
10121    }
10122
10123    #[test]
10124    fn configured_env_ref_missing_from_store_fails_closed() {
10125        let dir = tempfile::tempdir().unwrap();
10126        let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
10127        let mut config = Config::default();
10128        config.env_vars.push(EnvVarEntry {
10129            name: "TOKEN".to_string(),
10130            value: String::new(),
10131            secret: true,
10132            value_encrypted: None,
10133            credential_ref: Some(reference),
10134            configured: true,
10135            description: None,
10136        });
10137        let error = config
10138            .hydrate_env_var_credentials_from_store(dir.path())
10139            .unwrap_err();
10140        assert!(error
10141            .to_string()
10142            .contains("referenced env credential is unavailable"));
10143        assert!(config.env_vars[0].configured);
10144        assert!(config.env_vars[0].value.is_empty());
10145    }
10146
10147    #[test]
10148    fn configured_notification_ref_missing_from_store_fails_closed() {
10149        let dir = tempfile::tempdir().unwrap();
10150        let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
10151        let mut config = Config::default();
10152        config.notifications.ntfy.credential_ref = Some(reference);
10153        config.notifications.ntfy.configured = true;
10154
10155        let error = config
10156            .hydrate_notification_credentials_from_store(dir.path())
10157            .unwrap_err();
10158
10159        assert!(error
10160            .to_string()
10161            .contains("referenced ntfy credential is unavailable"));
10162        assert!(config.notifications.ntfy.configured);
10163        assert!(config.notifications.ntfy.token.is_none());
10164    }
10165
10166    #[test]
10167    fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
10168        let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
10169        let dir = tempfile::tempdir().unwrap();
10170        let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
10171        crate::CredentialStore::open(dir.path())
10172            .replace(
10173                reference.clone(),
10174                "exclusive-notification-secret",
10175                crate::CredentialSource::User,
10176                0,
10177            )
10178            .unwrap();
10179
10180        let mut config = Config::default();
10181        config.notifications.ntfy.credential_ref = Some(reference.clone());
10182        config.notifications.ntfy.configured = true;
10183        config.proxy_auth_credential_ref = Some(reference.clone());
10184        let error = config
10185            .hydrate_notification_credentials_from_store(dir.path())
10186            .unwrap_err();
10187        let rendered = error.to_string();
10188        assert!(rendered
10189            .contains("notification credential reference is shared by another config consumer"));
10190        assert!(!rendered.contains(reference.as_str()));
10191        assert!(config.notifications.ntfy.token.is_none());
10192
10193        config.proxy_auth_credential_ref = None;
10194        config
10195            .hydrate_notification_credentials_from_store(dir.path())
10196            .unwrap();
10197        assert_eq!(
10198            config.notifications.ntfy.token.as_deref(),
10199            Some("exclusive-notification-secret")
10200        );
10201    }
10202
10203    #[test]
10204    fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
10205        let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
10206        let dir = tempfile::tempdir().unwrap();
10207        let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
10208        let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
10209        let bytes = serde_json::to_vec_pretty(&serde_json::json!({
10210            "notifications": {
10211                "ntfy": { "enabled": true, "token_encrypted": ntfy },
10212                "bark": { "enabled": true, "device_key_encrypted": bark }
10213            }
10214        }))
10215        .unwrap();
10216        std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
10217        std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
10218
10219        let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
10220
10221        assert!(loaded.notifications.ntfy.token.is_none());
10222        assert!(loaded.notifications.bark.device_key.is_none());
10223        assert_eq!(
10224            std::fs::read(dir.path().join("config.json")).unwrap(),
10225            bytes
10226        );
10227    }
10228
10229    #[test]
10230    fn publish_and_current_env_vars_round_trip() {
10231        // `publish_env_vars` REPLACES the process-global env-vars cache
10232        // wholesale, so every test that touches that cache must hold the
10233        // crate-wide env lock. This test didn't (issue #486): running
10234        // concurrently with a lock-holding cache test (e.g.
10235        // `from_data_dir_without_publish_does_not_clobber_global_cache`) it
10236        // wiped that test's just-seeded marker out of the cache mid-assert —
10237        // and its own 10x retry loop below was itself a symptom of losing
10238        // the same race in the other direction. With the lock held, one
10239        // publish is deterministic.
10240        let _lock = crate::test_support::env_cache_lock_acquire();
10241        let mut config = Config::default();
10242        config.env_vars.extend([EnvVarEntry {
10243            name: "TEST_PUBLISH".to_string(),
10244            value: "pub_value".to_string(),
10245            secret: false,
10246            value_encrypted: None,
10247            credential_ref: None,
10248            configured: true,
10249            description: None,
10250        }]);
10251
10252        config.publish_env_vars();
10253        assert_eq!(
10254            Config::current_env_vars()
10255                .get("TEST_PUBLISH")
10256                .map(String::as_str),
10257            Some("pub_value")
10258        );
10259    }
10260
10261    #[test]
10262    fn broker_token_round_trips_encrypt_sanitize_hydrate() {
10263        let mut config = Config::default();
10264        config.subagents.broker = Some(BrokerClientConfig {
10265            endpoint: "ws://127.0.0.1:9600".to_string(),
10266            token: "super-secret-token".to_string(),
10267            token_encrypted: None,
10268            credential_ref: None,
10269            configured: false,
10270        });
10271
10272        // Persist path: encrypt then sanitize (what save_to_dir does).
10273        config.refresh_broker_token_encrypted().unwrap();
10274        config.sanitize_broker_token_for_disk();
10275        let broker = config.subagents.broker.as_ref().unwrap();
10276        assert!(broker.token.is_empty(), "plaintext cleared for disk");
10277        assert!(broker.token_encrypted.is_some(), "ciphertext stored");
10278        assert_ne!(
10279            broker.token_encrypted.as_deref(),
10280            Some("super-secret-token")
10281        );
10282
10283        // Load path: hydrate restores plaintext.
10284        config.hydrate_broker_token_from_encrypted();
10285        assert_eq!(
10286            config.subagents.broker.as_ref().unwrap().token,
10287            "super-secret-token"
10288        );
10289    }
10290
10291    #[test]
10292    fn broker_token_empty_refresh_preserves_ciphertext() {
10293        // A redacted round-trip (token empty) must not wipe the stored ciphertext.
10294        let mut config = Config::default();
10295        config.subagents.broker = Some(BrokerClientConfig {
10296            endpoint: "ws://h:9600".to_string(),
10297            token: String::new(),
10298            token_encrypted: Some("existing-cipher".to_string()),
10299            credential_ref: None,
10300            configured: false,
10301        });
10302        config.refresh_broker_token_encrypted().unwrap();
10303        assert_eq!(
10304            config
10305                .subagents
10306                .broker
10307                .as_ref()
10308                .unwrap()
10309                .token_encrypted
10310                .as_deref(),
10311            Some("existing-cipher"),
10312        );
10313    }
10314
10315    #[test]
10316    fn notifications_config_defaults_when_key_missing() {
10317        // Additive/back-compat: an absent `notifications` key must deserialize
10318        // to the built-in defaults (desktop auto, ntfy/bark disabled).
10319        let config: Config = serde_json::from_str("{}").expect("empty object parses");
10320        assert_eq!(config.notifications, NotificationsConfig::default());
10321        assert_eq!(config.notifications.desktop.enabled, None);
10322        assert!(!config.notifications.ntfy.enabled);
10323        assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10324        assert_eq!(config.notifications.ntfy.token, None);
10325        assert!(!config.notifications.bark.enabled);
10326        assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10327        assert_eq!(config.notifications.bark.device_key, None);
10328    }
10329
10330    #[test]
10331    fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10332        let mut config = Config::default();
10333        config.notifications.ntfy = NtfyChannelConfig {
10334            enabled: true,
10335            base_url: "https://ntfy.sh".to_string(),
10336            topic: "bamboo-alerts".to_string(),
10337            token: Some("tk_super_secret".to_string()),
10338            token_encrypted: None,
10339            credential_ref: None,
10340            configured: false,
10341        };
10342
10343        // Legacy compatibility path: retain a readable ciphertext until the
10344        // credential migration moves the secret into the isolated store.
10345        config.refresh_notifications_encrypted().unwrap();
10346        assert!(config.notifications.ntfy.token_encrypted.is_some());
10347        assert_ne!(
10348            config.notifications.ntfy.token_encrypted.as_deref(),
10349            Some("tk_super_secret")
10350        );
10351
10352        // Neither plaintext nor legacy ciphertext is serializable.
10353        let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10354        assert!(
10355            !json.contains("tk_super_secret"),
10356            "plaintext token must never be serialized"
10357        );
10358        assert!(!json.contains("token_encrypted"));
10359
10360        // Legacy load path restores plaintext for migration.
10361        config.notifications.ntfy.token = None;
10362        config.hydrate_notifications_from_encrypted();
10363        assert_eq!(
10364            config.notifications.ntfy.token.as_deref(),
10365            Some("tk_super_secret")
10366        );
10367    }
10368
10369    #[test]
10370    fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10371        let mut config = Config::default();
10372        config.notifications.bark = BarkChannelConfig {
10373            enabled: true,
10374            base_url: "https://api.day.app".to_string(),
10375            device_key: Some("dk_super_secret".to_string()),
10376            device_key_encrypted: None,
10377            credential_ref: None,
10378            configured: false,
10379        };
10380
10381        config.refresh_notifications_encrypted().unwrap();
10382        assert!(config.notifications.bark.device_key_encrypted.is_some());
10383        assert_ne!(
10384            config.notifications.bark.device_key_encrypted.as_deref(),
10385            Some("dk_super_secret")
10386        );
10387
10388        let json = serde_json::to_string(&config.notifications.bark).unwrap();
10389        assert!(
10390            !json.contains("dk_super_secret"),
10391            "plaintext device key must never be serialized"
10392        );
10393        assert!(!json.contains("device_key_encrypted"));
10394
10395        config.notifications.bark.device_key = None;
10396        config.hydrate_notifications_from_encrypted();
10397        assert_eq!(
10398            config.notifications.bark.device_key.as_deref(),
10399            Some("dk_super_secret")
10400        );
10401    }
10402
10403    #[test]
10404    fn notification_secrets_empty_refresh_preserves_ciphertext() {
10405        // The legacy compatibility helper retains already-loaded ciphertext;
10406        // ordinary persistence sanitizes it before writing.
10407        let mut config = Config::default();
10408        config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10409        config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10410
10411        config.refresh_notifications_encrypted().unwrap();
10412
10413        assert_eq!(
10414            config.notifications.ntfy.token_encrypted.as_deref(),
10415            Some("existing-ntfy-cipher")
10416        );
10417        assert_eq!(
10418            config.notifications.bark.device_key_encrypted.as_deref(),
10419            Some("existing-bark-cipher")
10420        );
10421    }
10422
10423    #[test]
10424    fn hydrate_skips_non_secret_entries() {
10425        let mut config = Config::default();
10426        config.env_vars.extend([EnvVarEntry {
10427            name: "PLAIN".to_string(),
10428            value: "original".to_string(),
10429            secret: false,
10430            value_encrypted: Some("should-be-ignored".to_string()),
10431            credential_ref: None,
10432            configured: true,
10433            description: None,
10434        }]);
10435
10436        config.hydrate_env_vars_from_encrypted();
10437        // Non-secret entry should keep its original value
10438        assert_eq!(config.env_vars[0].value, "original");
10439    }
10440
10441    #[test]
10442    fn default_config_has_empty_env_vars() {
10443        // `Config::default()` is a pure in-memory constructor (no disk read, no
10444        // env overrides), so this is independent of the developer's
10445        // `~/.bamboo/config.json` — no temp-dir isolation needed. Directly
10446        // asserts the #38 invariant that default() does not touch the filesystem.
10447        assert!(Config::default().env_vars.is_empty());
10448    }
10449
10450    #[test]
10451    fn serde_round_trip_with_env_vars() {
10452        let mut config = Config::default();
10453        config.env_vars.extend([
10454            EnvVarEntry {
10455                name: "KEY1".to_string(),
10456                value: "val1".to_string(),
10457                secret: false,
10458                value_encrypted: None,
10459                credential_ref: None,
10460                configured: true,
10461                description: Some("First key".to_string()),
10462            },
10463            EnvVarEntry {
10464                name: "KEY2".to_string(),
10465                value: "".to_string(), // on-disk secret has no plaintext
10466                secret: true,
10467                value_encrypted: Some("enc123".to_string()),
10468                credential_ref: None,
10469                configured: true,
10470                description: None,
10471            },
10472        ]);
10473
10474        let json = serde_json::to_string(&config).unwrap();
10475        let restored: Config = serde_json::from_str(&json).unwrap();
10476
10477        assert_eq!(restored.env_vars.len(), 2);
10478        assert_eq!(restored.env_vars[0].name, "KEY1");
10479        assert_eq!(restored.env_vars[0].value, "val1");
10480        assert!(!restored.env_vars[0].secret);
10481        assert_eq!(restored.env_vars[1].name, "KEY2");
10482        assert!(restored.env_vars[1].secret);
10483        assert!(restored.env_vars[1].value_encrypted.is_none());
10484    }
10485
10486    // ---- defaults.* model resolution tests ----
10487
10488    #[test]
10489    // fields set conditionally below
10490    #[allow(clippy::field_reassign_with_default)]
10491    fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10492        let mut config = Config::default();
10493        config.provider = "openai".to_string();
10494        config.providers.openai = Some(OpenAIConfig {
10495            api_key: "test".to_string(),
10496            api_key_encrypted: None,
10497            credential_ref: None,
10498            base_url: None,
10499            model: Some("legacy-gpt-4o".to_string()),
10500            fast_model: None,
10501            vision_model: None,
10502            reasoning_effort: None,
10503            responses_only_models: vec![],
10504            request_overrides: None,
10505            extra: Default::default(),
10506            api_key_from_env: false,
10507        });
10508        config.features.provider_model_ref = true;
10509        config.defaults = Some(DefaultsConfig {
10510            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10511            fast: None,
10512            task_summary: None,
10513            vision: None,
10514            memory_background: None,
10515            planning: None,
10516            search: None,
10517            code_review: None,
10518            sub_agent: None,
10519            subagent_models: Default::default(),
10520        });
10521
10522        assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10523    }
10524
10525    #[test]
10526    // fields set conditionally below
10527    #[allow(clippy::field_reassign_with_default)]
10528    fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10529        let mut config = Config::default();
10530        config.provider = "openai".to_string();
10531        config.providers.openai = Some(OpenAIConfig {
10532            api_key: "test".to_string(),
10533            api_key_encrypted: None,
10534            credential_ref: None,
10535            base_url: None,
10536            model: Some("legacy-gpt-4o".to_string()),
10537            fast_model: None,
10538            vision_model: None,
10539            reasoning_effort: None,
10540            responses_only_models: vec![],
10541            request_overrides: None,
10542            extra: Default::default(),
10543            api_key_from_env: false,
10544        });
10545        config.features.provider_model_ref = false;
10546        config.defaults = Some(DefaultsConfig {
10547            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10548            fast: None,
10549            task_summary: None,
10550            vision: None,
10551            memory_background: None,
10552            planning: None,
10553            search: None,
10554            code_review: None,
10555            sub_agent: None,
10556            subagent_models: Default::default(),
10557        });
10558
10559        assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10560    }
10561
10562    #[test]
10563    // fields set conditionally below
10564    #[allow(clippy::field_reassign_with_default)]
10565    fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10566        let mut config = Config::default();
10567        config.provider = "openai".to_string();
10568        config.providers.openai = Some(OpenAIConfig {
10569            api_key: "test".to_string(),
10570            api_key_encrypted: None,
10571            credential_ref: None,
10572            base_url: None,
10573            model: Some("gpt-4o".to_string()),
10574            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10575            vision_model: None,
10576            reasoning_effort: None,
10577            responses_only_models: vec![],
10578            request_overrides: None,
10579            extra: Default::default(),
10580            api_key_from_env: false,
10581        });
10582        config.features.provider_model_ref = true;
10583        config.defaults = Some(DefaultsConfig {
10584            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10585            fast: Some(bamboo_domain::ProviderModelRef::new(
10586                "anthropic",
10587                "claude-3-5-haiku",
10588            )),
10589            task_summary: None,
10590            vision: None,
10591            memory_background: None,
10592            planning: None,
10593            search: None,
10594            code_review: None,
10595            sub_agent: None,
10596            subagent_models: Default::default(),
10597        });
10598
10599        assert_eq!(
10600            config.get_fast_model(),
10601            Some("claude-3-5-haiku".to_string())
10602        );
10603    }
10604
10605    #[test]
10606    // fields set conditionally below
10607    #[allow(clippy::field_reassign_with_default)]
10608    fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10609        let mut config = Config::default();
10610        config.provider = "openai".to_string();
10611        config.providers.openai = Some(OpenAIConfig {
10612            api_key: "test".to_string(),
10613            api_key_encrypted: None,
10614            credential_ref: None,
10615            base_url: None,
10616            model: Some("gpt-4o".to_string()),
10617            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10618            vision_model: None,
10619            reasoning_effort: None,
10620            responses_only_models: vec![],
10621            request_overrides: None,
10622            extra: Default::default(),
10623            api_key_from_env: false,
10624        });
10625        config.features.provider_model_ref = false;
10626        config.defaults = Some(DefaultsConfig {
10627            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10628            fast: Some(bamboo_domain::ProviderModelRef::new(
10629                "anthropic",
10630                "claude-3-5-haiku",
10631            )),
10632            task_summary: None,
10633            vision: None,
10634            memory_background: None,
10635            planning: None,
10636            search: None,
10637            code_review: None,
10638            sub_agent: None,
10639            subagent_models: Default::default(),
10640        });
10641
10642        assert_eq!(
10643            config.get_fast_model(),
10644            Some("legacy-gpt-4o-mini".to_string())
10645        );
10646    }
10647
10648    #[test]
10649    // fields set conditionally below
10650    #[allow(clippy::field_reassign_with_default)]
10651    fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10652        let mut config = Config::default();
10653        config.provider = "openai".to_string();
10654        config.features.provider_model_ref = true;
10655        config.defaults = Some(DefaultsConfig {
10656            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10657            fast: None,
10658            task_summary: None,
10659            vision: None,
10660            memory_background: None,
10661            planning: None,
10662            search: None,
10663            code_review: None,
10664            sub_agent: None,
10665            subagent_models: Default::default(),
10666        });
10667
10668        assert_eq!(
10669            config.get_fast_model(),
10670            Some("claude-3-7-sonnet".to_string())
10671        );
10672    }
10673
10674    #[test]
10675    // fields set conditionally below
10676    #[allow(clippy::field_reassign_with_default)]
10677    fn get_memory_background_model_prefers_defaults_memory_background() {
10678        let mut config = Config::default();
10679        config.provider = "openai".to_string();
10680        config.providers.openai = Some(OpenAIConfig {
10681            api_key: "test".to_string(),
10682            api_key_encrypted: None,
10683            credential_ref: None,
10684            base_url: None,
10685            model: Some("gpt-4o".to_string()),
10686            fast_model: Some("gpt-4o-mini".to_string()),
10687            vision_model: None,
10688            reasoning_effort: None,
10689            responses_only_models: vec![],
10690            request_overrides: None,
10691            extra: Default::default(),
10692            api_key_from_env: false,
10693        });
10694        config.features.provider_model_ref = true;
10695        config.defaults = Some(DefaultsConfig {
10696            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10697            fast: Some(bamboo_domain::ProviderModelRef::new(
10698                "openai",
10699                "gpt-4o-mini",
10700            )),
10701            task_summary: None,
10702            vision: None,
10703            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10704                "anthropic",
10705                "claude-3-5-haiku",
10706            )),
10707            planning: None,
10708            search: None,
10709            code_review: None,
10710            sub_agent: None,
10711            subagent_models: Default::default(),
10712        });
10713
10714        assert_eq!(
10715            config.get_memory_background_model(),
10716            Some("claude-3-5-haiku".to_string())
10717        );
10718    }
10719
10720    #[test]
10721    // fields set conditionally below
10722    #[allow(clippy::field_reassign_with_default)]
10723    fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10724        let mut config = Config::default();
10725        config.provider = "openai".to_string();
10726        config.features.provider_model_ref = true;
10727        config.defaults = Some(DefaultsConfig {
10728            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10729            fast: Some(bamboo_domain::ProviderModelRef::new(
10730                "anthropic",
10731                "claude-3-5-haiku",
10732            )),
10733            task_summary: None,
10734            vision: None,
10735            memory_background: None,
10736            planning: None,
10737            search: None,
10738            code_review: None,
10739            sub_agent: None,
10740            subagent_models: Default::default(),
10741        });
10742
10743        assert_eq!(
10744            config.get_memory_background_model(),
10745            Some("claude-3-5-haiku".to_string())
10746        );
10747    }
10748
10749    #[test]
10750    // fields set conditionally below
10751    #[allow(clippy::field_reassign_with_default)]
10752    fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10753        let mut config = Config::default();
10754        config.provider = "openai".to_string();
10755        config.providers.openai = Some(OpenAIConfig {
10756            api_key: "test".to_string(),
10757            api_key_encrypted: None,
10758            credential_ref: None,
10759            base_url: None,
10760            model: Some("gpt-4o".to_string()),
10761            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10762            vision_model: None,
10763            reasoning_effort: None,
10764            responses_only_models: vec![],
10765            request_overrides: None,
10766            extra: Default::default(),
10767            api_key_from_env: false,
10768        });
10769        config.features.provider_model_ref = false;
10770        config.defaults = Some(DefaultsConfig {
10771            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10772            fast: Some(bamboo_domain::ProviderModelRef::new(
10773                "anthropic",
10774                "claude-3-5-haiku",
10775            )),
10776            task_summary: None,
10777            vision: None,
10778            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10779                "anthropic",
10780                "claude-3-5-haiku",
10781            )),
10782            planning: None,
10783            search: None,
10784            code_review: None,
10785            sub_agent: None,
10786            subagent_models: Default::default(),
10787        });
10788
10789        assert_eq!(
10790            config.get_memory_background_model(),
10791            Some("legacy-gpt-4o-mini".to_string())
10792        );
10793    }
10794
10795    // -------------------------------------------------------------------
10796    // `is_host_trusted` — plugin source-trust host allowlist (component
10797    // matching, not raw string-prefix matching; see the function's own docs
10798    // for the bypasses this closes).
10799    // -------------------------------------------------------------------
10800
10801    #[test]
10802    fn is_host_trusted_requires_https_scheme() {
10803        let hosts = vec!["github.com/bigduu/".to_string()];
10804        assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10805        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10806    }
10807
10808    #[test]
10809    fn is_host_trusted_is_case_insensitive_on_both_sides() {
10810        // A lowercase URL host against a mixed-case config entry...
10811        let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10812        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10813        // ...and a mixed-case URL host against a lowercase config entry.
10814        let hosts = vec!["github.com/bigduu/".to_string()];
10815        assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10816    }
10817
10818    #[test]
10819    fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10820        let hosts = vec!["trusted.example.com".to_string()];
10821        assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10822        // Both demonstrated bypasses of a raw string-prefix match: gluing a
10823        // longer attacker-controlled label onto the trusted host, with or
10824        // without a separating dot.
10825        assert!(!is_host_trusted(
10826            "https://trusted.example.com.evil.com/x",
10827            &hosts
10828        ));
10829        assert!(!is_host_trusted(
10830            "https://trusted.example.comevil.com/x",
10831            &hosts
10832        ));
10833    }
10834
10835    #[test]
10836    fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10837        // No trailing slash on the config entry's path component.
10838        let hosts = vec!["github.com/bigduu/".to_string()];
10839        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10840        assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10841    }
10842
10843    #[test]
10844    fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10845        let hosts = vec!["example.com".to_string()];
10846        assert!(is_host_trusted("https://example.com/", &hosts));
10847        assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10848        // Still only that exact host — a bare-host entry must not become a
10849        // blanket "any host containing this string" match.
10850        assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10851        assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10852    }
10853
10854    #[test]
10855    fn is_host_trusted_uses_the_real_host_not_userinfo() {
10856        let hosts = vec!["github.com/bigduu/".to_string()];
10857        // `user@host` userinfo does not change the actual host.
10858        assert!(is_host_trusted(
10859            "https://someuser@github.com/bigduu/x",
10860            &hosts
10861        ));
10862        // A decoy host placed in the userinfo position must not be mistaken
10863        // for the real host — the real host here is `evil.com`.
10864        assert!(!is_host_trusted(
10865            "https://github.com@evil.com/bigduu/",
10866            &hosts
10867        ));
10868    }
10869
10870    #[test]
10871    fn is_host_trusted_ignores_an_explicit_port() {
10872        let hosts = vec!["github.com/bigduu/".to_string()];
10873        assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10874    }
10875
10876    #[test]
10877    fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10878        let hosts = vec!["github.com/bigduu/".to_string()];
10879        assert!(!is_host_trusted("not a url at all", &hosts));
10880        assert!(!is_host_trusted("", &hosts));
10881        assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); // no scheme
10882    }
10883
10884    #[test]
10885    fn is_host_trusted_normalizes_dot_segments_before_matching() {
10886        let hosts = vec!["github.com/bigduu/".to_string()];
10887        // `Url::parse` resolves `..` segments before `path()` is ever
10888        // consulted, so this cannot be used to escape the trusted prefix.
10889        assert!(!is_host_trusted(
10890            "https://github.com/bigduu/../evil/x",
10891            &hosts
10892        ));
10893        // A `..` that stays under the trusted prefix once resolved is fine.
10894        assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10895    }
10896
10897    #[test]
10898    fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10899        let mut config = Config::default();
10900        config.plugin_trust.trusted_hosts = vec![
10901            "  GitHub.com/BigDuu/ ".to_string(),
10902            "".to_string(),
10903            "   ".to_string(),
10904            "Example.COM".to_string(),
10905        ];
10906        config.normalize_plugin_trust_settings();
10907        assert_eq!(
10908            config.plugin_trust.trusted_hosts,
10909            vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10910        );
10911    }
10912
10913    // -----------------------------------------------------------------
10914    // `plugin_trust.enforcement` — the persistent, config-level form of the
10915    // `--insecure` escape hatch.
10916    // -----------------------------------------------------------------
10917
10918    #[test]
10919    fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10920        // A fresh `Config::default()` (nothing on disk at all).
10921        let config = Config::default();
10922        assert_eq!(
10923            config.plugin_trust.enforcement,
10924            PluginTrustEnforcement::Strict
10925        );
10926        assert!(!config.plugin_trust.enforcement_is_off());
10927
10928        // A `plugin_trust` object present in JSON but with NO `enforcement`
10929        // key at all (e.g. a config.json written before this field existed)
10930        // must ALSO deserialize to Strict, not fail or silently do something
10931        // else — additive/back-compat, matching `trusted_hosts`/
10932        // `trusted_keys`'s own `#[serde(default = ...)]` behavior.
10933        let json = serde_json::json!({
10934            "trusted_hosts": ["example.com"],
10935            "trusted_keys": [],
10936        });
10937        let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10938        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10939    }
10940
10941    #[test]
10942    fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10943        for raw in ["off", "OFF", "Off", " off "] {
10944            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10945                "enforcement": raw,
10946            }))
10947            .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10948            assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10949            assert!(trust.enforcement_is_off());
10950        }
10951        for raw in ["strict", "STRICT", " Strict "] {
10952            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10953                "enforcement": raw,
10954            }))
10955            .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10956            assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10957        }
10958
10959        let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10960            "enforcement": "nonsense",
10961        }))
10962        .expect_err("an unrecognized string must be rejected, not silently default");
10963        assert!(err.to_string().contains("nonsense"));
10964    }
10965
10966    #[test]
10967    fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10968        // A hand-edited config.json using a plain bool reads naturally: is
10969        // enforcement ON (`true`) or OFF (`false`)?
10970        let trust: PluginTrustConfig =
10971            serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10972        assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10973
10974        let trust: PluginTrustConfig =
10975            serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10976        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10977    }
10978
10979    #[test]
10980    fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10981        // Regardless of which accepted input form produced it, the
10982        // in-memory value always serializes back out as the canonical
10983        // string — this is what the dot-path `config set` setter's
10984        // round-trip check relies on (see `dot_path.rs`'s module docs).
10985        let trust = PluginTrustConfig {
10986            enforcement: PluginTrustEnforcement::Off,
10987            ..PluginTrustConfig::default()
10988        };
10989        let json = serde_json::to_value(&trust).unwrap();
10990        assert_eq!(json["enforcement"], "off");
10991
10992        let trust = PluginTrustConfig {
10993            enforcement: PluginTrustEnforcement::Strict,
10994            ..PluginTrustConfig::default()
10995        };
10996        let json = serde_json::to_value(&trust).unwrap();
10997        assert_eq!(json["enforcement"], "strict");
10998    }
10999
11000    #[test]
11001    fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
11002        // `normalize_plugin_trust_settings` only touches `trusted_hosts` —
11003        // confirm it's a true no-op on `enforcement` either way.
11004        let mut config = Config::default();
11005        config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
11006        config.normalize_plugin_trust_settings();
11007        assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
11008    }
11009
11010    #[test]
11011    fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
11012        // Confirms the dot-path `bamboo config set plugin_trust.enforcement
11013        // off` path actually works end to end through
11014        // `crate::dot_path::apply_dot_path_set` (the generic JSON-patch
11015        // setter), not just direct field assignment.
11016        let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
11017            "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
11018        )));
11019        assert_eq!(
11020            config.plugin_trust.enforcement,
11021            PluginTrustEnforcement::Strict
11022        );
11023
11024        let outcome = crate::dot_path::apply_dot_path_set(
11025            &config,
11026            "plugin_trust.enforcement",
11027            crate::dot_path::parse_cli_value("off"),
11028        )
11029        .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
11030        assert_eq!(
11031            outcome.config.plugin_trust.enforcement,
11032            PluginTrustEnforcement::Off
11033        );
11034
11035        // And back to strict.
11036        let outcome = crate::dot_path::apply_dot_path_set(
11037            &outcome.config,
11038            "plugin_trust.enforcement",
11039            crate::dot_path::parse_cli_value("strict"),
11040        )
11041        .expect("setting it back to strict should also round-trip");
11042        assert_eq!(
11043            outcome.config.plugin_trust.enforcement,
11044            PluginTrustEnforcement::Strict
11045        );
11046    }
11047}
11048
11049#[cfg(test)]
11050mod final_evidence_feature_tests {
11051    use super::FeatureFlags;
11052
11053    #[test]
11054    fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
11055        let existing: FeatureFlags =
11056            serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
11057        assert!(!existing.final_evidence_check);
11058        let enabled: FeatureFlags =
11059            serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
11060        let restored: FeatureFlags =
11061            serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
11062        assert!(restored.final_evidence_check);
11063        assert!(!FeatureFlags::default().final_evidence_check);
11064    }
11065}