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    pub max_input_tokens: std::num::NonZeroU32,
1799    pub max_output_tokens: std::num::NonZeroU32,
1800    pub max_evidence_bytes: std::num::NonZeroUsize,
1801    pub max_evidence_refs: std::num::NonZeroUsize,
1802}
1803
1804impl PatrolModelConfig {
1805    pub fn validate(&self) -> Result<(), String> {
1806        if self.provider.is_empty()
1807            || self.provider.len() > 256
1808            || self
1809                .provider
1810                .chars()
1811                .any(|ch| ch.is_whitespace() || ch.is_control())
1812        {
1813            return Err("patrol_model requires an explicit provider identifier".into());
1814        }
1815        if self.max_input_tokens.get() > MAX_INPUT_TOKENS
1816            || self.max_output_tokens.get() > MAX_OUTPUT_TOKENS
1817            || self.max_evidence_bytes.get() > MAX_EVIDENCE_BYTES
1818            || self.max_evidence_refs.get() > MAX_EVIDENCE_REFS
1819        {
1820            return Err(format!(
1821                "patrol_model limits must be at most {MAX_INPUT_TOKENS} input tokens, \
1822                 {MAX_OUTPUT_TOKENS} output tokens, {MAX_EVIDENCE_BYTES} evidence bytes, \
1823                 and {MAX_EVIDENCE_REFS} evidence references"
1824            ));
1825        }
1826        match self.protocol {
1827            PatrolModelProtocol::LlamaCppRawV1 if self.model == "gpt-oss-20b" => Ok(()),
1828            PatrolModelProtocol::LlamaCppRawV1 => {
1829                Err("llama_cpp_raw_v1 requires the gpt-oss-20b patrol model".into())
1830            }
1831        }
1832    }
1833}
1834
1835/// Main configuration structure for Bamboo agent
1836///
1837/// Contains all settings needed to run the agent, including provider credentials,
1838/// proxy settings, model selection, and server configuration.
1839#[derive(Debug, Clone, Serialize, Deserialize)]
1840#[doc(hidden)]
1841pub struct ConfigValues {
1842    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
1843    #[serde(default)]
1844    pub http_proxy: String,
1845    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
1846    #[serde(default)]
1847    pub https_proxy: String,
1848    /// Proxy authentication credentials
1849    ///
1850    /// Kept in memory only; ordinary config stores `proxy_auth_credential_ref`.
1851    #[serde(skip_serializing)]
1852    pub proxy_auth: Option<ProxyAuth>,
1853    /// Legacy encrypted proxy authentication accepted only for migration.
1854    #[serde(default, skip_serializing_if = "Option::is_none")]
1855    pub proxy_auth_encrypted: Option<String>,
1856    /// Stable credential-store reference for proxy authentication.
1857    #[serde(default, skip_serializing_if = "Option::is_none")]
1858    pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1859    /// Deprecated: Use `providers.copilot.headless_auth` instead
1860    #[serde(default)]
1861    pub headless_auth: bool,
1862
1863    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
1864    #[serde(default = "default_provider")]
1865    pub provider: String,
1866
1867    /// Default model assignments (used when features.provider_model_ref is enabled).
1868    #[serde(default, skip_serializing_if = "Option::is_none")]
1869    pub defaults: Option<DefaultsConfig>,
1870
1871    /// Multi-instance provider configurations keyed by instance id.
1872    ///
1873    /// When `provider_instances` is non-empty, the registry and router prefer
1874    /// instance ids as routing keys. Legacy `providers` / `provider` fields are
1875    /// still supported for backward compatibility; see
1876    /// [`Config::synthesize_legacy_instances`].
1877    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1878    pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1879
1880    /// The default provider instance id used when a request does not specify one.
1881    ///
1882    /// When set, this takes precedence over the legacy `provider` field.
1883    #[serde(default, skip_serializing_if = "Option::is_none")]
1884    pub default_provider_instance: Option<String>,
1885
1886    /// HTTP server configuration
1887    #[serde(default)]
1888    pub server: ServerConfig,
1889
1890    /// Global keyword masking configuration.
1891    ///
1892    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
1893    #[serde(default)]
1894    pub keyword_masking: KeywordMaskingConfig,
1895
1896    /// Anthropic model mapping configuration.
1897    ///
1898    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
1899    #[serde(default)]
1900    pub anthropic_model_mapping: AnthropicModelMapping,
1901
1902    /// Gemini model mapping configuration.
1903    ///
1904    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
1905    #[serde(default)]
1906    pub gemini_model_mapping: GeminiModelMapping,
1907
1908    /// Explicit model selection for offline semantic patrol input preparation.
1909    #[serde(default, skip_serializing_if = "Option::is_none")]
1910    pub patrol_model: Option<PatrolModelConfig>,
1911
1912    /// Request preflight hooks.
1913    ///
1914    /// These hooks can inspect and rewrite outgoing requests before they are sent upstream
1915    /// (e.g. image fallback behavior for text-only models).
1916    #[serde(default)]
1917    pub hooks: HooksConfig,
1918
1919    /// User-configured agent lifecycle command or external script handlers.
1920    ///
1921    /// This is intentionally separate from `hooks`, which is already the
1922    /// provider HTTP request-hook namespace. Lifecycle hooks are snapshotted
1923    /// when an agent run starts.
1924    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1925    pub lifecycle_hooks: LifecycleHooksConfig,
1926
1927    /// Global tool toggles.
1928    ///
1929    /// Any tool listed in `disabled` is omitted from the tool schemas sent to the LLM.
1930    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1931    pub tools: ToolsConfig,
1932
1933    /// Global skill toggles.
1934    ///
1935    /// Any skill listed in `disabled` is excluded from skill context construction and
1936    /// cannot be loaded through the skill runtime tools.
1937    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1938    pub skills: SkillsConfig,
1939
1940    /// User-managed environment variables injected into Bash tool processes.
1941    ///
1942    /// Secret entries are encrypted at rest; plaintext values are hydrated in memory.
1943    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1944    pub env_vars: Vec<EnvVarEntry>,
1945
1946    /// Default work area used when a session has no explicit active workspace.
1947    #[serde(default, skip_serializing_if = "Option::is_none")]
1948    pub default_work_area: Option<DefaultWorkAreaConfig>,
1949
1950    #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
1951    pub worktrees: WorktreeSettings,
1952
1953    /// Access control / password gate configuration.
1954    #[serde(default, skip_serializing_if = "Option::is_none")]
1955    pub access_control: Option<AccessControlConfig>,
1956
1957    /// Feature flags for incremental rollout.
1958    #[serde(default)]
1959    pub features: FeatureFlags,
1960
1961    /// Config-level default per-run token/tool-call/subagent budget (issue
1962    /// #221). `None` fields are unlimited. A per-request `ExecuteRequest`
1963    /// override may only tighten these ceilings, never loosen them; see
1964    /// [`RunBudgetConfig::merged_with_override`].
1965    #[serde(default)]
1966    pub run_budget: RunBudgetConfig,
1967
1968    /// LLM stream liveness and semantic-progress watchdog policy.
1969    #[serde(default)]
1970    pub stream_timeout: StreamTimeoutConfig,
1971
1972    /// Initial-request retry policy for transient transport failures and
1973    /// retryable upstream statuses. Operator-tunable so a flaky or
1974    /// heavily-queued relay can be tolerated without an environment variable
1975    /// and a process restart.
1976    ///
1977    /// `None` means "not configured": the environment-derived policy stays in
1978    /// force, so upgrading a deployment that tunes `BAMBOO_LLM_RETRY_*` does
1979    /// not silently replace those settings with the built-in defaults.
1980    #[serde(default, skip_serializing_if = "Option::is_none")]
1981    pub llm_retry: Option<LlmRetryConfig>,
1982
1983    /// Host-owned strategy for bounding provider-visible conversation context.
1984    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
1985    pub context_management: ContextManagementConfig,
1986
1987    /// Remote Cluster Fabric: operator-managed nodes & clusters for deploying
1988    /// `broker-agent` workers locally or over SSH. Additive/back-compat: absent
1989    /// ⇒ empty. SSH secrets are encrypted at rest (see [`crate::cluster_fabric`]).
1990    #[serde(
1991        default,
1992        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1993    )]
1994    pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1995
1996    /// MCP server configuration.
1997    ///
1998    /// Previously persisted in `mcp.json` (now unified into `config.json`).
1999    // On disk we use the mainstream `mcpServers` key (matching Claude Desktop / MCP ecosystem
2000    // conventions). We still accept the legacy `mcp` key for backward compatibility.
2001    #[serde(default, rename = "mcpServers", alias = "mcp")]
2002    pub mcp: bamboo_domain::mcp_config::McpConfig,
2003
2004    /// Notification delivery channels (desktop + push-relay services).
2005    /// Secrets (ntfy token, Bark device key) live in the isolated credential
2006    /// store; only stable references/configured metadata persist here.
2007    #[serde(default)]
2008    pub notifications: NotificationsConfig,
2009
2010    /// bamboo-connect IM-platform bridges (Telegram first, #452 / epic #447).
2011    /// Secrets (each platform's `token`) are encrypted at rest — see
2012    /// [`Config::hydrate_connect_platform_tokens_from_encrypted`] /
2013    /// [`Config::refresh_connect_platform_tokens_encrypted`].
2014    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2015    pub connect: ConnectConfig,
2016
2017    /// Plugin URL-install source-trust policy (host allowlist + ed25519
2018    /// publisher keys). See [`PluginTrustConfig`]'s docs for the three-layer
2019    /// model this stacks with the checksum layer.
2020    #[serde(default)]
2021    pub plugin_trust: PluginTrustConfig,
2022
2023    /// Extension fields stored at the root of `config.json`.
2024    ///
2025    /// This keeps the config forward-compatible and allows unrelated subsystems
2026    /// (e.g. setup UI state) to persist their own keys without getting dropped by
2027    /// typed (de)serialization.
2028    #[serde(default, flatten)]
2029    pub extra: BTreeMap<String, Value>,
2030}
2031
2032impl Default for ConfigValues {
2033    fn default() -> Self {
2034        Self {
2035            http_proxy: String::new(),
2036            https_proxy: String::new(),
2037            proxy_auth: None,
2038            proxy_auth_encrypted: None,
2039            proxy_auth_credential_ref: None,
2040            headless_auth: false,
2041            run_budget: RunBudgetConfig::default(),
2042            stream_timeout: StreamTimeoutConfig::default(),
2043            llm_retry: None,
2044            context_management: ContextManagementConfig::default(),
2045            cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
2046            provider: default_provider(),
2047            provider_instances: HashMap::new(),
2048            default_provider_instance: None,
2049            server: ServerConfig::default(),
2050            keyword_masking: KeywordMaskingConfig::default(),
2051            anthropic_model_mapping: AnthropicModelMapping::default(),
2052            gemini_model_mapping: GeminiModelMapping::default(),
2053            patrol_model: None,
2054            hooks: HooksConfig::default(),
2055            lifecycle_hooks: LifecycleHooksConfig::default(),
2056            tools: ToolsConfig::default(),
2057            skills: SkillsConfig::default(),
2058            env_vars: Vec::new(),
2059            default_work_area: None,
2060            worktrees: WorktreeSettings::default(),
2061            access_control: None,
2062            features: FeatureFlags::default(),
2063            defaults: None,
2064            mcp: bamboo_domain::mcp_config::McpConfig::default(),
2065            notifications: NotificationsConfig::default(),
2066            connect: ConnectConfig::default(),
2067            plugin_trust: PluginTrustConfig::default(),
2068            extra: BTreeMap::new(),
2069        }
2070    }
2071}
2072
2073/// Network-facing root configuration. Flattening preserves the historical
2074/// top-level JSON keys while making the persisted root structurally modular.
2075#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2076struct NetworkConfigSection {
2077    #[serde(default)]
2078    http_proxy: String,
2079    #[serde(default)]
2080    https_proxy: String,
2081    #[serde(skip_serializing)]
2082    proxy_auth: Option<ProxyAuth>,
2083    #[serde(default, skip_serializing_if = "Option::is_none")]
2084    proxy_auth_encrypted: Option<String>,
2085    #[serde(default, skip_serializing_if = "Option::is_none")]
2086    proxy_auth_credential_ref: Option<crate::CredentialRef>,
2087    #[serde(default)]
2088    headless_auth: bool,
2089    #[serde(default)]
2090    server: ServerConfig,
2091}
2092
2093#[derive(Debug, Clone, Serialize, Deserialize)]
2094struct ProviderRoutingConfigSection {
2095    #[serde(default = "default_provider")]
2096    provider: String,
2097    #[serde(default, skip_serializing_if = "Option::is_none")]
2098    defaults: Option<DefaultsConfig>,
2099    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2100    provider_instances: HashMap<String, ProviderInstanceConfig>,
2101    #[serde(default, skip_serializing_if = "Option::is_none")]
2102    default_provider_instance: Option<String>,
2103}
2104
2105impl Default for ProviderRoutingConfigSection {
2106    fn default() -> Self {
2107        Self {
2108            provider: default_provider(),
2109            defaults: None,
2110            provider_instances: HashMap::new(),
2111            default_provider_instance: None,
2112        }
2113    }
2114}
2115
2116#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2117struct ModelBehaviorConfigSection {
2118    #[serde(default)]
2119    keyword_masking: KeywordMaskingConfig,
2120    #[serde(default)]
2121    anthropic_model_mapping: AnthropicModelMapping,
2122    #[serde(default)]
2123    gemini_model_mapping: GeminiModelMapping,
2124    #[serde(default, skip_serializing_if = "Option::is_none")]
2125    patrol_model: Option<PatrolModelConfig>,
2126    #[serde(default)]
2127    hooks: HooksConfig,
2128    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
2129    lifecycle_hooks: LifecycleHooksConfig,
2130}
2131
2132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2133struct ToolingConfigSection {
2134    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
2135    tools: ToolsConfig,
2136    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
2137    skills: SkillsConfig,
2138    #[serde(default, rename = "mcpServers", alias = "mcp")]
2139    mcp: bamboo_domain::mcp_config::McpConfig,
2140}
2141
2142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2143struct WorkspaceConfigSection {
2144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2145    env_vars: Vec<EnvVarEntry>,
2146    #[serde(default, skip_serializing_if = "Option::is_none")]
2147    default_work_area: Option<DefaultWorkAreaConfig>,
2148    #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
2149    worktrees: WorktreeSettings,
2150    #[serde(default, skip_serializing_if = "Option::is_none")]
2151    access_control: Option<AccessControlConfig>,
2152}
2153
2154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2155struct ExecutionConfigSection {
2156    #[serde(default)]
2157    features: FeatureFlags,
2158    #[serde(default)]
2159    run_budget: RunBudgetConfig,
2160    #[serde(default)]
2161    stream_timeout: StreamTimeoutConfig,
2162    #[serde(default, skip_serializing_if = "Option::is_none")]
2163    llm_retry: Option<LlmRetryConfig>,
2164    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2165    context_management: ContextManagementConfig,
2166    #[serde(
2167        default,
2168        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2169    )]
2170    cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2171}
2172
2173#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2174struct IntegrationConfigSection {
2175    #[serde(default)]
2176    notifications: NotificationsConfig,
2177    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2178    connect: ConnectConfig,
2179}
2180
2181#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2182struct PluginSecurityConfigSection {
2183    #[serde(default)]
2184    plugin_trust: PluginTrustConfig,
2185}
2186
2187// Count declarations from the same field list that defines each structural
2188// budgeted type, so the constants cannot drift from the actual structs.
2189macro_rules! count_fields {
2190    ($($field:ident),* $(,)?) => {
2191        <[()]>::len(&[$(count_fields!(@one $field)),*])
2192    };
2193    (@one $field:ident) => { () };
2194}
2195
2196macro_rules! define_counted_struct {
2197    (
2198        $(#[$struct_meta:meta])*
2199        $visibility:vis struct $name:ident {
2200            $(
2201                $(#[$field_meta:meta])*
2202                $field_visibility:vis $field:ident: $field_type:ty
2203            ),* $(,)?
2204        }
2205        count $count_visibility:vis $count_name:ident;
2206    ) => {
2207        $(#[$struct_meta])*
2208        $visibility struct $name {
2209            $(
2210                $(#[$field_meta])*
2211                $field_visibility $field: $field_type,
2212            )*
2213        }
2214
2215        $count_visibility const $count_name: usize = count_fields!($($field),*);
2216    };
2217}
2218
2219define_counted_struct! {
2220    /// The root-only persistence DTO written to `config.json`.
2221    ///
2222    /// Every section is flattened so existing documents keep their historical
2223    /// top-level shape. The structural field count is nevertheless nine rather
2224    /// than the Phase-#39 baseline of 31.
2225    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
2226    struct ConfigRoot {
2227        #[serde(flatten)]
2228        network: NetworkConfigSection,
2229        #[serde(flatten)]
2230        provider_routing: ProviderRoutingConfigSection,
2231        #[serde(flatten)]
2232        model_behavior: ModelBehaviorConfigSection,
2233        #[serde(flatten)]
2234        tooling: ToolingConfigSection,
2235        #[serde(flatten)]
2236        workspace: WorkspaceConfigSection,
2237        #[serde(flatten)]
2238        execution: ExecutionConfigSection,
2239        #[serde(flatten)]
2240        integrations: IntegrationConfigSection,
2241        #[serde(flatten)]
2242        plugin_security: PluginSecurityConfigSection,
2243        #[serde(default, flatten)]
2244        extra: BTreeMap<String, Value>,
2245    }
2246    count pub PERSISTED_ROOT_FIELD_COUNT;
2247}
2248
2249impl From<ConfigValues> for ConfigRoot {
2250    fn from(values: ConfigValues) -> Self {
2251        // Deliberately exhaustive: adding a runtime compatibility field must
2252        // update its persisted section mapping or this conversion stops compiling.
2253        let ConfigValues {
2254            http_proxy,
2255            https_proxy,
2256            proxy_auth,
2257            proxy_auth_encrypted,
2258            proxy_auth_credential_ref,
2259            headless_auth,
2260            provider,
2261            defaults,
2262            provider_instances,
2263            default_provider_instance,
2264            server,
2265            keyword_masking,
2266            anthropic_model_mapping,
2267            gemini_model_mapping,
2268            patrol_model,
2269            hooks,
2270            lifecycle_hooks,
2271            tools,
2272            skills,
2273            env_vars,
2274            default_work_area,
2275            worktrees,
2276            access_control,
2277            features,
2278            run_budget,
2279            stream_timeout,
2280            llm_retry,
2281            context_management,
2282            cluster_fabric,
2283            mcp,
2284            notifications,
2285            connect,
2286            plugin_trust,
2287            extra,
2288        } = values;
2289
2290        Self {
2291            network: NetworkConfigSection {
2292                http_proxy,
2293                https_proxy,
2294                proxy_auth,
2295                proxy_auth_encrypted,
2296                proxy_auth_credential_ref,
2297                headless_auth,
2298                server,
2299            },
2300            provider_routing: ProviderRoutingConfigSection {
2301                provider,
2302                defaults,
2303                provider_instances,
2304                default_provider_instance,
2305            },
2306            model_behavior: ModelBehaviorConfigSection {
2307                keyword_masking,
2308                anthropic_model_mapping,
2309                gemini_model_mapping,
2310                patrol_model,
2311                hooks,
2312                lifecycle_hooks,
2313            },
2314            tooling: ToolingConfigSection { tools, skills, mcp },
2315            workspace: WorkspaceConfigSection {
2316                env_vars,
2317                default_work_area,
2318                worktrees,
2319                access_control,
2320            },
2321            execution: ExecutionConfigSection {
2322                features,
2323                run_budget,
2324                stream_timeout,
2325                llm_retry,
2326                context_management,
2327                cluster_fabric,
2328            },
2329            integrations: IntegrationConfigSection {
2330                notifications,
2331                connect,
2332            },
2333            plugin_security: PluginSecurityConfigSection { plugin_trust },
2334            extra,
2335        }
2336    }
2337}
2338
2339impl From<ConfigRoot> for ConfigValues {
2340    fn from(root: ConfigRoot) -> Self {
2341        // Keep every root and section destructure exhaustive so a newly added
2342        // persisted field cannot be silently omitted from the runtime view.
2343        let ConfigRoot {
2344            network,
2345            provider_routing,
2346            model_behavior,
2347            tooling,
2348            workspace,
2349            execution,
2350            integrations,
2351            plugin_security,
2352            extra,
2353        } = root;
2354        let NetworkConfigSection {
2355            http_proxy,
2356            https_proxy,
2357            proxy_auth,
2358            proxy_auth_encrypted,
2359            proxy_auth_credential_ref,
2360            headless_auth,
2361            server,
2362        } = network;
2363        let ProviderRoutingConfigSection {
2364            provider,
2365            defaults,
2366            provider_instances,
2367            default_provider_instance,
2368        } = provider_routing;
2369        let ModelBehaviorConfigSection {
2370            keyword_masking,
2371            anthropic_model_mapping,
2372            gemini_model_mapping,
2373            patrol_model,
2374            hooks,
2375            lifecycle_hooks,
2376        } = model_behavior;
2377        let ToolingConfigSection { tools, skills, mcp } = tooling;
2378        let WorkspaceConfigSection {
2379            env_vars,
2380            default_work_area,
2381            worktrees,
2382            access_control,
2383        } = workspace;
2384        let ExecutionConfigSection {
2385            features,
2386            run_budget,
2387            stream_timeout,
2388            llm_retry,
2389            context_management,
2390            cluster_fabric,
2391        } = execution;
2392        let IntegrationConfigSection {
2393            notifications,
2394            connect,
2395        } = integrations;
2396        let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2397
2398        Self {
2399            http_proxy,
2400            https_proxy,
2401            proxy_auth,
2402            proxy_auth_encrypted,
2403            proxy_auth_credential_ref,
2404            headless_auth,
2405            provider,
2406            defaults,
2407            provider_instances,
2408            default_provider_instance,
2409            server,
2410            keyword_masking,
2411            anthropic_model_mapping,
2412            gemini_model_mapping,
2413            patrol_model,
2414            hooks,
2415            lifecycle_hooks,
2416            tools,
2417            skills,
2418            env_vars,
2419            default_work_area,
2420            worktrees,
2421            access_control,
2422            features,
2423            run_budget,
2424            stream_timeout,
2425            llm_retry,
2426            context_management,
2427            cluster_fabric,
2428            mcp,
2429            notifications,
2430            connect,
2431            plugin_trust,
2432            extra,
2433        }
2434    }
2435}
2436
2437/// Serialize the root-only durable document.
2438///
2439/// Public `Config` serialization intentionally retains the historical
2440/// compatibility shape, including the legacy `provider` selector. Durable
2441/// instance-native writes are narrower: the explicit instance default is the
2442/// routing authority, so legacy routing fields must not be written back.
2443fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2444    let instance_native = values
2445        .default_provider_instance
2446        .as_ref()
2447        .is_some_and(|id| values.provider_instances.contains_key(id));
2448    let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2449    if instance_native {
2450        if let Some(object) = value.as_object_mut() {
2451            object.remove("provider");
2452            object.remove("providers");
2453        }
2454    }
2455    Ok(value)
2456}
2457
2458define_counted_struct! {
2459    /// Runtime configuration facade. Phase-1 sidecar domains are typed modules;
2460    /// the remaining values keep field-access compatibility through `Deref`.
2461    #[derive(Debug, Clone)]
2462    pub struct Config {
2463        values: ConfigValues,
2464        pub(crate) memory: crate::MemoryConfigModule,
2465        pub(crate) subagents: crate::SubagentsConfigModule,
2466        pub(crate) providers: crate::ProviderConfigsModule,
2467        recovery_status: Option<ConfigRecoveryStatus>,
2468    }
2469    count pub CONFIG_FIELD_COUNT;
2470}
2471
2472/// Auditable structural budgets from Issue #590.
2473pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2474const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2475const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2476
2477impl std::ops::Deref for Config {
2478    type Target = ConfigValues;
2479
2480    fn deref(&self) -> &Self::Target {
2481        &self.values
2482    }
2483}
2484
2485impl std::ops::DerefMut for Config {
2486    fn deref_mut(&mut self) -> &mut Self::Target {
2487        &mut self.values
2488    }
2489}
2490
2491impl Serialize for Config {
2492    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2493    where
2494        S: serde::Serializer,
2495    {
2496        self.to_compatibility_value()
2497            .map_err(serde::ser::Error::custom)?
2498            .serialize(serializer)
2499    }
2500}
2501
2502impl<'de> Deserialize<'de> for Config {
2503    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2504    where
2505        D: serde::Deserializer<'de>,
2506    {
2507        use serde::de::Error;
2508
2509        let mut value = Value::deserialize(deserializer)?;
2510        let object = value
2511            .as_object_mut()
2512            .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2513        let memory = object
2514            .remove("memory")
2515            .map(serde_json::from_value)
2516            .transpose()
2517            .map_err(D::Error::custom)?
2518            .unwrap_or_default();
2519        let subagents = object
2520            .remove("subagents")
2521            .map(serde_json::from_value)
2522            .transpose()
2523            .map_err(D::Error::custom)?
2524            .unwrap_or_default();
2525        let providers = object
2526            .remove("providers")
2527            .map(serde_json::from_value)
2528            .transpose()
2529            .map_err(D::Error::custom)?
2530            .unwrap_or_default();
2531        let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2532        if let Some(model) = &root.model_behavior.patrol_model {
2533            model.validate().map_err(D::Error::custom)?;
2534        }
2535
2536        Ok(Self::from_parts(root.into(), memory, subagents, providers))
2537    }
2538}
2539
2540/// Where a [`ConfigRecoveryStatus`]'s recovered values came from. #153.
2541#[derive(Debug, Clone, PartialEq, Serialize)]
2542#[serde(tag = "kind", rename_all = "snake_case")]
2543pub enum ConfigRecoverySource {
2544    /// Field-by-field salvage from the corrupt file itself
2545    /// ([`Config::salvage_partial`]); `fields` lists the top-level keys that
2546    /// were recovered from the corrupt document (any other field fell back to
2547    /// the backup/default baseline instead).
2548    Salvaged { fields: Vec<String> },
2549    /// Recovered wholesale from a `config.json.bak[.N]` generation
2550    /// (`generation` 0 == `.bak`, 1 == `.bak.1`, …).
2551    Backup { generation: usize },
2552    /// No usable salvage or backup; fell back to built-in defaults.
2553    Defaults,
2554}
2555
2556/// Describes a pending config-corruption recovery (#153, following on from
2557/// #37/#135's quarantine + salvage/backup chain): `config.json` failed to
2558/// parse at load time, the corrupt original was quarantined (copied aside,
2559/// not deleted) to `quarantine_path`, and the owning [`Config`] holds the
2560/// recovered in-memory state instead.
2561///
2562/// [`Config::save_to_dir`] refuses to overwrite `config.json` while
2563/// `confirmed` is `false`, so a user who would rather hand-fix the original
2564/// isn't surprised by an automatic overwrite on the next save. Call
2565/// [`Config::confirm_recovery`] (or [`Config::confirm_recovery_and_save_to_dir`])
2566/// to allow the next save through.
2567#[derive(Debug, Clone, PartialEq, Serialize)]
2568pub struct ConfigRecoveryStatus {
2569    /// Where the recovered values came from.
2570    pub source: ConfigRecoverySource,
2571    /// Absolute path of the preserved copy of the corrupt original
2572    /// (`config.json.corrupted.<nanos>`), or `None` if even the quarantine
2573    /// copy failed (the corrupt original still remains in place at
2574    /// `config.json` itself either way — quarantining copies, it doesn't
2575    /// move — so the guard below still applies).
2576    pub quarantine_path: Option<PathBuf>,
2577    /// Set `true` once the user has explicitly confirmed the recovery; only
2578    /// then may `save_to_dir` persist over the original `config.json`.
2579    pub confirmed: bool,
2580}
2581
2582/// Container for provider-specific configurations
2583///
2584/// Each field is optional, allowing users to configure only the providers they need.
2585#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2586pub struct ProviderConfigs {
2587    /// OpenAI provider configuration
2588    #[serde(skip_serializing_if = "Option::is_none")]
2589    pub openai: Option<OpenAIConfig>,
2590    /// Anthropic provider configuration
2591    #[serde(skip_serializing_if = "Option::is_none")]
2592    pub anthropic: Option<AnthropicConfig>,
2593    /// Google Gemini provider configuration
2594    #[serde(skip_serializing_if = "Option::is_none")]
2595    pub gemini: Option<GeminiConfig>,
2596    /// GitHub Copilot provider configuration
2597    #[serde(skip_serializing_if = "Option::is_none")]
2598    pub copilot: Option<CopilotConfig>,
2599    /// Bodhi proxy provider configuration
2600    #[serde(skip_serializing_if = "Option::is_none")]
2601    pub bodhi: Option<BodhiConfig>,
2602
2603    /// Preserve unknown provider keys (forward compatibility).
2604    #[serde(default, flatten)]
2605    pub extra: BTreeMap<String, Value>,
2606}
2607
2608impl ProviderConfigs {
2609    /// Remove only the known legacy selector and built-in aliases while
2610    /// preserving unknown forward-compatible provider entries in `extra`.
2611    pub fn clear_legacy_builtin_aliases(&mut self) {
2612        self.openai = None;
2613        self.anthropic = None;
2614        self.gemini = None;
2615        self.copilot = None;
2616        self.bodhi = None;
2617        self.extra.remove("provider");
2618    }
2619}
2620
2621/// Feature flags for incremental rollout of new subsystems.
2622#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2623pub struct FeatureFlags {
2624    /// File-authoritative Tickets for a newly bootstrapped Supervisor only.
2625    #[serde(default)]
2626    pub ticket_mutation: bool,
2627    /// Actual fresh one-shot dispatch; independent of Ticket record writes.
2628    #[serde(default)]
2629    pub ticket_dispatch: bool,
2630    /// Enable the ProviderModelRef system (multi-provider + unified model selection).
2631    #[serde(default)]
2632    pub provider_model_ref: bool,
2633    /// Enable MiniLoop-based complexity evaluation and dynamic per-round model switching.
2634    #[serde(default)]
2635    pub dynamic_model_routing: bool,
2636    /// Check a final answer once against this request's recorded tool evidence.
2637    #[serde(default)]
2638    pub final_evidence_check: bool,
2639}
2640
2641/// Default model assignments for specific capabilities.
2642///
2643/// Used when `features.provider_model_ref` is enabled.
2644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2645pub struct DefaultsConfig {
2646    pub chat: bamboo_domain::ProviderModelRef,
2647    #[serde(default, skip_serializing_if = "Option::is_none")]
2648    pub fast: Option<bamboo_domain::ProviderModelRef>,
2649    #[serde(default, skip_serializing_if = "Option::is_none")]
2650    pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2651    #[serde(default, skip_serializing_if = "Option::is_none")]
2652    pub vision: Option<bamboo_domain::ProviderModelRef>,
2653    #[serde(default, skip_serializing_if = "Option::is_none")]
2654    pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2655    /// Model for planning/coordination tasks (task decomposition, architecture).
2656    /// Falls back to `fast`, then `chat`, when unset.
2657    #[serde(default, skip_serializing_if = "Option::is_none")]
2658    pub planning: Option<bamboo_domain::ProviderModelRef>,
2659    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
2660    /// Falls back to `fast`, then `chat`, when unset.
2661    #[serde(default, skip_serializing_if = "Option::is_none")]
2662    pub search: Option<bamboo_domain::ProviderModelRef>,
2663    /// Model for code review tasks.
2664    /// Falls back to `fast`, then `chat`, when unset.
2665    #[serde(default, skip_serializing_if = "Option::is_none")]
2666    pub code_review: Option<bamboo_domain::ProviderModelRef>,
2667    /// Default model for child SubAgent runs.
2668    /// Falls back to `fast`, then `chat` when unset.
2669    #[serde(
2670        default,
2671        skip_serializing_if = "Option::is_none",
2672        alias = "sub_session"
2673    )]
2674    pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2675    /// Per-subagent-type model overrides.
2676    /// Key = subagent_type (e.g. "researcher", "coder"), Value = ProviderModelRef.
2677    /// Falls back to `sub_agent`, then `fast`, then `chat` when no match is found.
2678    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2679    pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2680}
2681
2682/// Request hook configuration.
2683#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2684pub struct HooksConfig {
2685    /// Image fallback behavior for OpenAI-compatible requests (chat/responses).
2686    #[serde(default)]
2687    pub image_fallback: ImageFallbackHookConfig,
2688}
2689
2690/// Default deadline for one lifecycle command hook.
2691pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2692/// Smallest accepted lifecycle hook deadline at the config API boundary.
2693pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2694/// Largest accepted lifecycle hook deadline (10 minutes). Lifecycle hooks run
2695/// inline with agent progress, so they share the same upper bound as Bamboo's
2696/// interactive shell tool instead of allowing an accidental hours-long stall.
2697pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2698
2699/// Stable user-facing event keys accepted by `lifecycle_hooks`.
2700pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 9] = [
2701    "SessionStart",
2702    "UserPromptSubmit",
2703    "PreToolUse",
2704    "PostToolUse",
2705    "Stop",
2706    "SessionEnd",
2707    "PreCompact",
2708    "Notification",
2709    "BambooModelCallUsage",
2710];
2711
2712fn default_lifecycle_hook_timeout_ms() -> u64 {
2713    DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2714}
2715
2716fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2717    *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2718}
2719
2720fn lifecycle_hook_enabled_default() -> bool {
2721    true
2722}
2723
2724fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2725    *value
2726}
2727
2728/// Config-driven agent lifecycle hooks.
2729///
2730/// Event names deliberately preserve the user-facing PascalCase protocol.
2731/// Server-owned events use the same stable schema as engine-owned events.
2732#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2733pub struct LifecycleHooksConfig {
2734    #[serde(default)]
2735    pub enabled: bool,
2736    #[serde(
2737        default,
2738        rename = "SessionStart",
2739        skip_serializing_if = "Vec::is_empty"
2740    )]
2741    pub session_start: Vec<LifecycleHookGroup>,
2742    #[serde(
2743        default,
2744        rename = "UserPromptSubmit",
2745        skip_serializing_if = "Vec::is_empty"
2746    )]
2747    pub user_prompt_submit: Vec<LifecycleHookGroup>,
2748    #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2749    pub pre_tool_use: Vec<LifecycleHookGroup>,
2750    #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2751    pub post_tool_use: Vec<LifecycleHookGroup>,
2752    #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2753    pub stop: Vec<LifecycleHookGroup>,
2754    #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2755    pub session_end: Vec<LifecycleHookGroup>,
2756    #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2757    pub pre_compact: Vec<LifecycleHookGroup>,
2758    #[serde(
2759        default,
2760        rename = "Notification",
2761        skip_serializing_if = "Vec::is_empty"
2762    )]
2763    pub notification: Vec<LifecycleHookGroup>,
2764    #[serde(
2765        default,
2766        rename = "BambooModelCallUsage",
2767        skip_serializing_if = "Vec::is_empty"
2768    )]
2769    pub model_call_usage: Vec<LifecycleHookGroup>,
2770}
2771
2772impl LifecycleHooksConfig {
2773    pub fn is_empty(&self) -> bool {
2774        !self.enabled
2775            && self.session_start.is_empty()
2776            && self.user_prompt_submit.is_empty()
2777            && self.pre_tool_use.is_empty()
2778            && self.post_tool_use.is_empty()
2779            && self.stop.is_empty()
2780            && self.session_end.is_empty()
2781            && self.pre_compact.is_empty()
2782            && self.notification.is_empty()
2783            && self.model_call_usage.is_empty()
2784    }
2785}
2786
2787/// A matcher and its ordered handler list for one lifecycle event.
2788#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2789pub struct LifecycleHookGroup {
2790    /// A disabled group remains persisted and editable but is not registered
2791    /// for execution. Missing values default to true for old config files.
2792    #[serde(
2793        default = "lifecycle_hook_enabled_default",
2794        skip_serializing_if = "lifecycle_hook_enabled_is_default"
2795    )]
2796    pub enabled: bool,
2797    #[serde(default, skip_serializing_if = "Option::is_none")]
2798    pub matcher: Option<String>,
2799    #[serde(default)]
2800    pub hooks: Vec<LifecycleHookHandler>,
2801}
2802
2803impl Default for LifecycleHookGroup {
2804    fn default() -> Self {
2805        Self {
2806            enabled: true,
2807            matcher: None,
2808            hooks: Vec::new(),
2809        }
2810    }
2811}
2812
2813/// One configured lifecycle hook handler.
2814///
2815/// The internally tagged representation preserves the existing command JSON
2816/// while allowing handler-specific validation for external scripts.
2817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2818#[serde(tag = "type", rename_all = "lowercase")]
2819pub enum LifecycleHookHandler {
2820    Command {
2821        command: String,
2822        #[serde(
2823            default = "default_lifecycle_hook_timeout_ms",
2824            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2825        )]
2826        timeout_ms: u64,
2827    },
2828    Script {
2829        path: String,
2830        #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2831        runner: LifecycleScriptRunner,
2832        #[serde(
2833            default = "default_lifecycle_hook_timeout_ms",
2834            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2835        )]
2836        timeout_ms: u64,
2837    },
2838}
2839
2840/// Runtime used to execute a lifecycle script.
2841///
2842/// `auto` infers the language from the file extension and tries the system
2843/// runtimes in a deterministic order. Explicit runners are useful when both
2844/// Node.js and Bun are installed or when a deployment standardizes one binary.
2845#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2846#[serde(rename_all = "lowercase")]
2847pub enum LifecycleScriptRunner {
2848    #[default]
2849    Auto,
2850    Node,
2851    Bun,
2852    Python,
2853    Bash,
2854    PowerShell,
2855    Cmd,
2856}
2857
2858impl LifecycleScriptRunner {
2859    pub fn is_auto(&self) -> bool {
2860        matches!(self, Self::Auto)
2861    }
2862
2863    pub fn as_str(self) -> &'static str {
2864        match self {
2865            Self::Auto => "auto",
2866            Self::Node => "node",
2867            Self::Bun => "bun",
2868            Self::Python => "python",
2869            Self::Bash => "bash",
2870            Self::PowerShell => "powershell",
2871            Self::Cmd => "cmd",
2872        }
2873    }
2874
2875    /// Whether this runner can execute the supplied supported script path.
2876    pub fn supports_path(self, path: &str) -> bool {
2877        let extension = lifecycle_script_extension(path);
2878        match self {
2879            Self::Auto => extension.is_some(),
2880            Self::Node | Self::Bun => {
2881                matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2882            }
2883            Self::Python => matches!(extension.as_deref(), Some("py")),
2884            Self::Bash => matches!(extension.as_deref(), Some("sh")),
2885            Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2886            Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2887        }
2888    }
2889}
2890
2891/// Return the normalized extension when the path names a supported lifecycle
2892/// script.
2893pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2894    let extension = std::path::Path::new(path)
2895        .extension()?
2896        .to_str()?
2897        .to_ascii_lowercase();
2898    matches!(
2899        extension.as_str(),
2900        "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2901    )
2902    .then_some(extension)
2903}
2904
2905impl LifecycleHookHandler {
2906    pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2907        Self::Command {
2908            command: command.into(),
2909            timeout_ms,
2910        }
2911    }
2912
2913    pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2914        Self::Script {
2915            path: path.into(),
2916            runner,
2917            timeout_ms,
2918        }
2919    }
2920
2921    pub fn timeout_ms(&self) -> u64 {
2922        match self {
2923            Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2924        }
2925    }
2926}
2927
2928/// Request override configuration for provider-specific HTTP behavior.
2929///
2930/// Overrides are merged in this order (later wins):
2931/// 1. `common`
2932/// 2. `endpoints[endpoint]`
2933/// 3. matching `rules` (sorted by specificity)
2934#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2935pub struct RequestOverridesConfig {
2936    /// Overrides applied to all endpoints.
2937    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2938    pub common: RequestScopeOverride,
2939    /// Endpoint-specific overrides (`chat_completions`, `responses`, `messages`, etc.).
2940    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2941    pub endpoints: BTreeMap<String, RequestScopeOverride>,
2942    /// Model-conditional overrides.
2943    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2944    pub rules: Vec<ModelRequestRule>,
2945}
2946
2947/// A conditional override rule matching a model pattern.
2948#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2949pub struct ModelRequestRule {
2950    /// Model pattern (exact: `gpt-4o`, prefix wildcard: `gpt-5*`).
2951    pub model_pattern: String,
2952    /// Optional endpoint constraint.
2953    #[serde(default, skip_serializing_if = "Option::is_none")]
2954    pub endpoint: Option<String>,
2955    /// Overrides applied when this rule matches.
2956    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2957    pub scope: RequestScopeOverride,
2958}
2959
2960/// Request overrides applied in a specific scope.
2961#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2962pub struct RequestScopeOverride {
2963    /// Extra or overridden HTTP headers.
2964    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2965    pub headers: BTreeMap<String, TemplateExpr>,
2966    /// JSON body patch operations.
2967    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2968    pub body_patch: Vec<BodyPatch>,
2969}
2970
2971impl RequestScopeOverride {
2972    pub fn is_empty(&self) -> bool {
2973        self.headers.is_empty() && self.body_patch.is_empty()
2974    }
2975}
2976
2977/// Body patch operation.
2978#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2979pub struct BodyPatch {
2980    /// Target path (`foo.bar.0` or `/foo/bar/0`).
2981    pub path: String,
2982    /// Operation type.
2983    #[serde(default)]
2984    pub op: BodyPatchOp,
2985    /// Value for `set` operation.
2986    #[serde(default, skip_serializing_if = "Option::is_none")]
2987    pub value: Option<PatchValue>,
2988}
2989
2990/// Supported body patch operations.
2991#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2992#[serde(rename_all = "snake_case")]
2993pub enum BodyPatchOp {
2994    #[default]
2995    Set,
2996    Remove,
2997}
2998
2999/// Body patch value: either a template expression or a raw JSON value.
3000#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3001#[serde(untagged)]
3002pub enum PatchValue {
3003    Template(TemplateExpr),
3004    Json(Value),
3005}
3006
3007/// String template expression used by headers/body patch values.
3008#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3009#[serde(untagged)]
3010pub enum TemplateExpr {
3011    /// Shorthand literal value.
3012    Literal(String),
3013    /// Structured template expression.
3014    Structured(TemplateExprSpec),
3015}
3016
3017/// Structured template expression.
3018#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3019#[serde(tag = "type", rename_all = "snake_case")]
3020pub enum TemplateExprSpec {
3021    /// Literal string value.
3022    Literal { value: String },
3023    /// Reference a value from Bamboo env vars.
3024    EnvRef {
3025        name: String,
3026        #[serde(default, skip_serializing_if = "Option::is_none")]
3027        fallback: Option<String>,
3028    },
3029    /// Generate a runtime value.
3030    Generated { generator: GeneratedValue },
3031    /// Format string with placeholders (`{env:NAME}`, `{uuid}`, `{unix_ms}`).
3032    Format { template: String },
3033}
3034
3035/// Supported generated value kinds.
3036#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3037#[serde(rename_all = "snake_case")]
3038pub enum GeneratedValue {
3039    Uuid,
3040    UnixMs,
3041}
3042
3043/// Global tool toggle configuration.
3044#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3045pub struct ToolsConfig {
3046    /// Tool names that are disabled globally.
3047    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3048    pub disabled: Vec<String>,
3049
3050    /// Preserve tool configuration owned by newer Bamboo versions or plugins.
3051    #[serde(default, flatten)]
3052    pub extra: BTreeMap<String, Value>,
3053}
3054
3055impl ToolsConfig {
3056    fn is_empty(&self) -> bool {
3057        self.disabled.is_empty() && self.extra.is_empty()
3058    }
3059}
3060
3061/// Global skill toggle configuration.
3062#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3063pub struct SkillsConfig {
3064    /// Skill IDs that are disabled globally.
3065    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3066    pub disabled: Vec<String>,
3067
3068    /// Preserve skill configuration owned by newer Bamboo versions or plugins.
3069    #[serde(default, flatten)]
3070    pub extra: BTreeMap<String, Value>,
3071}
3072
3073impl SkillsConfig {
3074    fn is_empty(&self) -> bool {
3075        self.disabled.is_empty() && self.extra.is_empty()
3076    }
3077}
3078
3079/// When a request contains image parts but the effective provider path is text-only,
3080/// we can either:
3081/// - error fast (preferred for strict setups), or
3082/// - degrade gracefully by replacing images with a placeholder text.
3083#[derive(Debug, Clone, Serialize, Deserialize)]
3084pub struct ImageFallbackHookConfig {
3085    #[serde(default = "default_true_hooks")]
3086    pub enabled: bool,
3087
3088    /// "placeholder" (default) or "error"
3089    #[serde(default = "default_image_fallback_mode")]
3090    pub mode: String,
3091}
3092
3093impl Default for ImageFallbackHookConfig {
3094    fn default() -> Self {
3095        Self {
3096            enabled: default_true_hooks(),
3097            mode: default_image_fallback_mode(),
3098        }
3099    }
3100}
3101
3102fn default_image_fallback_mode() -> String {
3103    "placeholder".to_string()
3104}
3105
3106fn default_true_hooks() -> bool {
3107    // Default to disabled so image inputs are preserved unless the user explicitly
3108    // opts into fallback rewriting (placeholder/error/ocr).
3109    false
3110}
3111
3112/// OpenAI provider configuration
3113///
3114/// # Example
3115///
3116/// ```json
3117/// "openai": {
3118///   "api_key": "sk-...",
3119///   "base_url": "https://api.openai.com/v1",
3120///   "model": "gpt-4"
3121/// }
3122/// ```
3123pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
3124
3125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3126pub struct OpenAIConfig {
3127    /// OpenAI API key (plaintext, in-memory only).
3128    ///
3129    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3130    #[serde(default, skip_serializing)]
3131    pub api_key: String,
3132    /// Encrypted OpenAI API key (nonce:ciphertext).
3133    #[serde(default, skip_serializing_if = "Option::is_none")]
3134    pub api_key_encrypted: Option<String>,
3135    /// Stable reference to the isolated credential store.
3136    #[serde(default, skip_serializing_if = "Option::is_none")]
3137    pub credential_ref: Option<crate::CredentialRef>,
3138    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3139    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3140    /// `api_key_encrypted` on save (that would bake the secret into
3141    /// config.json). Not (de)serialized. (#253)
3142    #[serde(skip)]
3143    pub api_key_from_env: bool,
3144    /// Custom API base URL (for Azure or self-hosted deployments)
3145    #[serde(skip_serializing_if = "Option::is_none")]
3146    pub base_url: Option<String>,
3147    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
3148    #[serde(skip_serializing_if = "Option::is_none")]
3149    pub model: Option<String>,
3150    /// Fast/cheap model for lightweight tasks (title generation and summarization).
3151    /// Falls back to `model` when not set.
3152    #[serde(default, skip_serializing_if = "Option::is_none")]
3153    pub fast_model: Option<String>,
3154    /// Vision-capable model for image understanding tasks.
3155    /// Falls back to `model` when not set.
3156    #[serde(default, skip_serializing_if = "Option::is_none")]
3157    pub vision_model: Option<String>,
3158    /// Default reasoning effort for OpenAI requests.
3159    #[serde(skip_serializing_if = "Option::is_none")]
3160    pub reasoning_effort: Option<ReasoningEffort>,
3161
3162    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3163    ///
3164    /// Example:
3165    /// ```json
3166    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3167    /// ```
3168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3169    pub responses_only_models: Vec<String>,
3170    /// Optional request overrides (headers/body patches/model rules).
3171    #[serde(default, skip_serializing_if = "Option::is_none")]
3172    pub request_overrides: Option<RequestOverridesConfig>,
3173
3174    /// Preserve unknown keys under `providers.openai`.
3175    #[serde(default, flatten)]
3176    pub extra: BTreeMap<String, Value>,
3177}
3178
3179impl OpenAIConfig {
3180    /// Whether Bamboo may lower its provider-neutral cache plan into GPT-5.6+
3181    /// `prompt_cache_options` and `prompt_cache_breakpoint` fields.
3182    ///
3183    /// This defaults to enabled. OpenAI-compatible upstreams that have not yet
3184    /// implemented the explicit-cache request fields can opt out without
3185    /// disabling `prompt_cache_key` or the upstream's implicit prompt cache.
3186    pub fn explicit_prompt_cache_enabled(&self) -> bool {
3187        self.extra
3188            .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3189            .and_then(Value::as_bool)
3190            .unwrap_or(true)
3191    }
3192}
3193
3194/// Anthropic provider configuration
3195///
3196/// # Example
3197///
3198/// ```json
3199/// "anthropic": {
3200///   "api_key": "sk-ant-...",
3201///   "model": "claude-3-5-sonnet-20241022",
3202///   "max_tokens": 4096
3203/// }
3204/// ```
3205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3206pub struct AnthropicConfig {
3207    /// Anthropic API key (plaintext, in-memory only).
3208    ///
3209    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3210    #[serde(default, skip_serializing)]
3211    pub api_key: String,
3212    /// Encrypted Anthropic API key (nonce:ciphertext).
3213    #[serde(default, skip_serializing_if = "Option::is_none")]
3214    pub api_key_encrypted: Option<String>,
3215    /// Stable reference to the isolated credential store.
3216    #[serde(default, skip_serializing_if = "Option::is_none")]
3217    pub credential_ref: Option<crate::CredentialRef>,
3218    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3219    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3220    /// `api_key_encrypted` on save (that would bake the secret into
3221    /// config.json). Not (de)serialized. (#253)
3222    #[serde(skip)]
3223    pub api_key_from_env: bool,
3224    /// Custom API base URL
3225    #[serde(skip_serializing_if = "Option::is_none")]
3226    pub base_url: Option<String>,
3227    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
3228    #[serde(skip_serializing_if = "Option::is_none")]
3229    pub model: Option<String>,
3230    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3231    /// Falls back to `model` when not set.
3232    #[serde(default, skip_serializing_if = "Option::is_none")]
3233    pub fast_model: Option<String>,
3234    /// Vision-capable model for image understanding tasks.
3235    /// Falls back to `model` when not set.
3236    #[serde(default, skip_serializing_if = "Option::is_none")]
3237    pub vision_model: Option<String>,
3238    /// Maximum tokens in model response
3239    #[serde(skip_serializing_if = "Option::is_none")]
3240    pub max_tokens: Option<u32>,
3241    /// Default reasoning effort for Anthropic requests.
3242    #[serde(skip_serializing_if = "Option::is_none")]
3243    pub reasoning_effort: Option<ReasoningEffort>,
3244    /// Optional request overrides (headers/body patches/model rules).
3245    #[serde(default, skip_serializing_if = "Option::is_none")]
3246    pub request_overrides: Option<RequestOverridesConfig>,
3247
3248    /// Unconditionally replay a prior turn's `reasoning` as a `thinking`
3249    /// content block, regardless of whether bamboo captured a valid signature
3250    /// for it (issue #520).
3251    ///
3252    /// Defaults to `false`/absent, which is REQUIRED for real Anthropic: it
3253    /// requires `thinking` input blocks to carry a signature it minted itself,
3254    /// and bamboo never captures one, so an unconditionally-replayed block is
3255    /// always rejected with a 400 (either because it's foreign — minted by a
3256    /// different provider after a mid-session model switch — or because it's
3257    /// an unsigned copy of Claude's own prior turn).
3258    ///
3259    /// Set this to `true` only when pointing `base_url` at an
3260    /// Anthropic-COMPATIBLE upstream (e.g. GLM's `/anthropic` endpoint) that
3261    /// has the opposite contract: it requires the `thinking` block to be
3262    /// present whenever thinking is enabled, but never validates its
3263    /// signature.
3264    #[serde(default, skip_serializing_if = "Option::is_none")]
3265    pub thinking_replay_always: Option<bool>,
3266
3267    /// Preserve unknown keys under `providers.anthropic`.
3268    #[serde(default, flatten)]
3269    pub extra: BTreeMap<String, Value>,
3270}
3271
3272/// Google Gemini provider configuration
3273///
3274/// # Example
3275///
3276/// ```json
3277/// "gemini": {
3278///   "api_key": "AIza...",
3279///   "model": "gemini-2.0-flash-exp"
3280/// }
3281/// ```
3282#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3283pub struct GeminiConfig {
3284    /// Google AI API key (plaintext, in-memory only).
3285    ///
3286    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3287    #[serde(default, skip_serializing)]
3288    pub api_key: String,
3289    /// Encrypted Google AI API key (nonce:ciphertext).
3290    #[serde(default, skip_serializing_if = "Option::is_none")]
3291    pub api_key_encrypted: Option<String>,
3292    /// Stable reference to the isolated credential store.
3293    #[serde(default, skip_serializing_if = "Option::is_none")]
3294    pub credential_ref: Option<crate::CredentialRef>,
3295    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3296    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3297    /// `api_key_encrypted` on save (that would bake the secret into
3298    /// config.json). Not (de)serialized. (#253)
3299    #[serde(skip)]
3300    pub api_key_from_env: bool,
3301    /// Custom API base URL
3302    #[serde(skip_serializing_if = "Option::is_none")]
3303    pub base_url: Option<String>,
3304    /// Default model to use (e.g., "gemini-2.0-flash-exp")
3305    #[serde(skip_serializing_if = "Option::is_none")]
3306    pub model: Option<String>,
3307    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3308    /// Falls back to `model` when not set.
3309    #[serde(default, skip_serializing_if = "Option::is_none")]
3310    pub fast_model: Option<String>,
3311    /// Vision-capable model for image understanding tasks.
3312    /// Falls back to `model` when not set.
3313    #[serde(default, skip_serializing_if = "Option::is_none")]
3314    pub vision_model: Option<String>,
3315    /// Default reasoning effort for Gemini requests.
3316    #[serde(skip_serializing_if = "Option::is_none")]
3317    pub reasoning_effort: Option<ReasoningEffort>,
3318    /// Optional request overrides (headers/body patches/model rules).
3319    #[serde(default, skip_serializing_if = "Option::is_none")]
3320    pub request_overrides: Option<RequestOverridesConfig>,
3321
3322    /// Preserve unknown keys under `providers.gemini`.
3323    #[serde(default, flatten)]
3324    pub extra: BTreeMap<String, Value>,
3325}
3326
3327/// GitHub Copilot provider configuration
3328///
3329/// # Example
3330///
3331/// ```json
3332/// "copilot": {
3333///   "enabled": true,
3334///   "headless_auth": false,
3335///   "model": "gpt-4o"
3336/// }
3337/// ```
3338#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3339pub struct CopilotConfig {
3340    /// Whether Copilot provider is enabled
3341    #[serde(default)]
3342    pub enabled: bool,
3343    /// Print login URL to console instead of opening browser
3344    #[serde(default)]
3345    pub headless_auth: bool,
3346    /// Default model to use for Copilot (used when clients request the "default" model)
3347    #[serde(skip_serializing_if = "Option::is_none")]
3348    pub model: Option<String>,
3349    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3350    /// Falls back to `model` when not set.
3351    #[serde(default, skip_serializing_if = "Option::is_none")]
3352    pub fast_model: Option<String>,
3353    /// Vision-capable model for image understanding tasks.
3354    /// Falls back to `model` when not set.
3355    #[serde(default, skip_serializing_if = "Option::is_none")]
3356    pub vision_model: Option<String>,
3357    /// Default reasoning effort for Copilot requests.
3358    #[serde(skip_serializing_if = "Option::is_none")]
3359    pub reasoning_effort: Option<ReasoningEffort>,
3360
3361    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3362    ///
3363    /// This is useful for newer Copilot models that only support Responses-style requests.
3364    ///
3365    /// Example:
3366    /// ```json
3367    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3368    /// ```
3369    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3370    pub responses_only_models: Vec<String>,
3371    /// Optional request overrides (headers/body patches/model rules).
3372    #[serde(default, skip_serializing_if = "Option::is_none")]
3373    pub request_overrides: Option<RequestOverridesConfig>,
3374
3375    /// Preserve unknown keys under `providers.copilot`.
3376    #[serde(default, flatten)]
3377    pub extra: BTreeMap<String, Value>,
3378}
3379
3380/// Bodhi proxy provider configuration.
3381///
3382/// Routes LLM requests through a bodhi-server instance so that raw provider
3383/// API keys never reach the client.
3384#[derive(Debug, Clone, Serialize, Deserialize)]
3385pub struct BodhiConfig {
3386    /// Bodhi server API key (e.g. "bhi_sk_xxx").  In-memory only.
3387    #[serde(default, skip_serializing)]
3388    pub api_key: String,
3389    /// Encrypted form of the API key stored on disk.
3390    #[serde(default, skip_serializing_if = "Option::is_none")]
3391    pub api_key_encrypted: Option<String>,
3392    /// Stable reference to the isolated credential store.
3393    #[serde(default, skip_serializing_if = "Option::is_none")]
3394    pub credential_ref: Option<crate::CredentialRef>,
3395    /// Bodhi server base URL.
3396    #[serde(skip_serializing_if = "Option::is_none")]
3397    pub base_url: Option<String>,
3398    /// Which upstream provider to route through bodhi ("openai", "anthropic", "gemini").
3399    #[serde(skip_serializing_if = "Option::is_none")]
3400    pub target_provider: Option<String>,
3401    /// Default reasoning effort.
3402    #[serde(skip_serializing_if = "Option::is_none")]
3403    pub reasoning_effort: Option<ReasoningEffort>,
3404
3405    /// Preserve unknown keys.
3406    #[serde(default, flatten)]
3407    pub extra: BTreeMap<String, Value>,
3408}
3409
3410/// Returns the default provider name ("anthropic")
3411fn default_provider() -> String {
3412    "anthropic".to_string()
3413}
3414
3415// ─── Provider Instance Configuration ──────────────────────────────────
3416
3417/// Configuration for a single provider instance.
3418///
3419/// Multiple instances of the same provider type (e.g. two OpenAI accounts)
3420/// can coexist. Each instance is identified by a stable `instance_id` that
3421/// is used as the routing key in [`ProviderModelRef::provider`] and the
3422/// provider registry.
3423///
3424/// # Example (config.json)
3425///
3426/// ```json
3427/// {
3428///   "provider_instances": {
3429///     "openai-work": {
3430///       "provider_type": "openai",
3431///       "label": "OpenAI (Work)",
3432///       "api_key": "sk-...",
3433///       "model": "gpt-4o"
3434///     },
3435///     "openai-personal": {
3436///       "provider_type": "openai",
3437///       "label": "OpenAI (Personal)",
3438///       "api_key": "sk-...",
3439///       "base_url": "https://api.openai.com/v1",
3440///       "model": "gpt-4o-mini"
3441///     }
3442///   },
3443///   "default_provider_instance": "openai-work"
3444/// }
3445/// ```
3446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3447pub struct ProviderInstanceConfig {
3448    /// Which provider backend this instance targets.
3449    ///
3450    /// Must be one of [`AVAILABLE_PROVIDERS`]: `"openai"`, `"anthropic"`,
3451    /// `"gemini"`, `"copilot"`, `"bodhi"`.
3452    pub provider_type: String,
3453
3454    /// Human-readable label shown in the UI / catalog.
3455    #[serde(default, skip_serializing_if = "Option::is_none")]
3456    pub label: Option<String>,
3457
3458    /// API key (plaintext in memory, encrypted at rest via `api_key_encrypted`).
3459    #[serde(default, skip_serializing)]
3460    pub api_key: String,
3461
3462    /// Encrypted API key (nonce:ciphertext). Written to disk; decrypted into
3463    /// `api_key` on load.
3464    #[serde(default, skip_serializing_if = "Option::is_none")]
3465    pub api_key_encrypted: Option<String>,
3466
3467    /// Stable reference to the isolated credential store.
3468    #[serde(default, skip_serializing_if = "Option::is_none")]
3469    pub credential_ref: Option<crate::CredentialRef>,
3470
3471    /// Custom base URL override.
3472    #[serde(default, skip_serializing_if = "Option::is_none")]
3473    pub base_url: Option<String>,
3474
3475    /// Default chat model for this instance.
3476    #[serde(default, skip_serializing_if = "Option::is_none")]
3477    pub model: Option<String>,
3478
3479    /// Fast/cheap model for lightweight tasks.
3480    #[serde(default, skip_serializing_if = "Option::is_none")]
3481    pub fast_model: Option<String>,
3482
3483    /// Vision-capable model.
3484    #[serde(default, skip_serializing_if = "Option::is_none")]
3485    pub vision_model: Option<String>,
3486
3487    /// Default reasoning effort.
3488    #[serde(default, skip_serializing_if = "Option::is_none")]
3489    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3490
3491    /// Models that must use the Responses API upstream (OpenAI only).
3492    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3493    pub responses_only_models: Vec<String>,
3494
3495    /// Optional request overrides (headers/body patches/model rules).
3496    #[serde(default, skip_serializing_if = "Option::is_none")]
3497    pub request_overrides: Option<RequestOverridesConfig>,
3498
3499    /// Whether this instance is enabled. Disabled instances are skipped
3500    /// during registry construction.
3501    #[serde(default = "default_true")]
3502    pub enabled: bool,
3503
3504    /// Provider-type-specific extra fields preserved through (de)serialization.
3505    #[serde(default, flatten)]
3506    pub extra: BTreeMap<String, Value>,
3507}
3508
3509fn default_true() -> bool {
3510    true
3511}
3512
3513/// Returns the default server port (9562)
3514fn default_port() -> u16 {
3515    9562
3516}
3517
3518/// Returns the default bind address (127.0.0.1)
3519fn default_bind() -> String {
3520    "127.0.0.1".to_string()
3521}
3522
3523/// Returns the default worker count (10)
3524fn default_workers() -> usize {
3525    10
3526}
3527
3528/// Returns the default data directory (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3529fn default_data_dir() -> PathBuf {
3530    super::paths::bamboo_dir()
3531}
3532
3533/// HTTP server configuration
3534#[derive(Debug, Clone, Serialize, Deserialize)]
3535pub struct ServerConfig {
3536    /// Port to listen on
3537    #[serde(default = "default_port")]
3538    pub port: u16,
3539
3540    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
3541    #[serde(default = "default_bind")]
3542    pub bind: String,
3543
3544    /// Static files directory (for Docker mode)
3545    pub static_dir: Option<PathBuf>,
3546
3547    /// Worker count for Actix-web
3548    #[serde(default = "default_workers")]
3549    pub workers: usize,
3550
3551    /// v2 (API v2 transport, #181): optional TLS termination config. When both
3552    /// `cert_file` and `key_file` are given, bamboo terminates TLS itself
3553    /// (rustls, no reverse proxy) and serves `https://` — intended for the
3554    /// public `0.0.0.0` face. When absent, the server keeps the plain `.bind()`
3555    /// / `.listen()` path unchanged (desktop loopback stays plaintext). Missing
3556    /// or unparseable cert/key files are fail-fast at startup, never a silent
3557    /// downgrade to plaintext.
3558    #[serde(default, skip_serializing_if = "Option::is_none")]
3559    pub tls: Option<TlsConfig>,
3560
3561    /// Preserve unknown keys under `server`.
3562    #[serde(default, flatten)]
3563    pub extra: BTreeMap<String, Value>,
3564}
3565
3566impl Default for ServerConfig {
3567    fn default() -> Self {
3568        Self {
3569            port: default_port(),
3570            bind: default_bind(),
3571            static_dir: None,
3572            workers: default_workers(),
3573            tls: None,
3574            extra: BTreeMap::new(),
3575        }
3576    }
3577}
3578
3579/// Manual TLS certificate configuration (current stage; ACME deferred).
3580///
3581/// Both fields point at PEM files: `cert_file` is the full certificate chain
3582/// (leaf → intermediates → root), `key_file` is the matching private key
3583/// (PKCS#8 or RSA). See `docs/api-v2-transport.md` §3.
3584#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3585pub struct TlsConfig {
3586    /// PEM certificate chain (leaf → intermediates → root).
3587    pub cert_file: PathBuf,
3588    /// PEM private key (PKCS#8 or RSA).
3589    pub key_file: PathBuf,
3590}
3591
3592/// Proxy authentication credentials
3593#[derive(Debug, Clone, Serialize, Deserialize)]
3594pub struct ProxyAuth {
3595    /// Proxy username
3596    pub username: String,
3597    /// Proxy password
3598    pub password: String,
3599}
3600
3601/// Parse a boolean value from environment variable strings
3602///
3603/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
3604fn parse_bool_env(value: &str) -> bool {
3605    matches!(
3606        value.trim().to_ascii_lowercase().as_str(),
3607        "1" | "true" | "yes" | "y" | "on"
3608    )
3609}
3610
3611fn expand_user_path(value: &str) -> PathBuf {
3612    let trimmed = value.trim();
3613    if let Some(rest) = trimmed.strip_prefix("~/") {
3614        if let Some(home) = dirs::home_dir() {
3615            return home.join(rest);
3616        }
3617    }
3618    PathBuf::from(trimmed)
3619}
3620
3621impl Default for Config {
3622    fn default() -> Self {
3623        // In-memory defaults ONLY. `default()` must not touch the filesystem or
3624        // environment: it was delegating to `new()` → `from_data_dir(None)`,
3625        // which read config.json from disk, applied BAMBOO_* env overrides, and
3626        // published to the global env-var cache. That made every `..Default::
3627        // default()` struct-update and every test silently disk-dependent and
3628        // let non-server callers clobber the server's in-memory config cache.
3629        // `create_default()` is the pure in-memory constructor; disk loading is
3630        // the explicit job of `new()` / `from_data_dir()`. #38.
3631        Self::create_default()
3632    }
3633}
3634
3635/// Prompt-safe snapshot of configured env vars.
3636#[derive(Debug, Clone, PartialEq, Eq)]
3637pub struct PromptSafeEnvVarEntry {
3638    pub name: String,
3639    pub secret: bool,
3640    pub description: Option<String>,
3641}
3642
3643/// Global cache of user-managed env vars for injection into child processes.
3644///
3645/// Updated whenever the config is loaded or reloaded via [`Config::publish_env_vars`].
3646static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3647
3648static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3649
3650fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3651    ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3652}
3653
3654fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3655    PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3656}
3657
3658impl Config {
3659    fn from_parts(
3660        values: ConfigValues,
3661        memory: Option<MemoryConfig>,
3662        subagents: SubagentsConfig,
3663        providers: ProviderConfigs,
3664    ) -> Self {
3665        Self {
3666            values,
3667            memory: crate::MemoryConfigModule(memory),
3668            subagents: crate::SubagentsConfigModule(subagents),
3669            providers: crate::ProviderConfigsModule(providers),
3670            recovery_status: None,
3671        }
3672    }
3673
3674    /// Crate-internal seam used by the modular section facade.
3675    ///
3676    /// Keeping this conversion here lets the facade exhaustively destructure
3677    /// [`ConfigValues`] in both directions without exposing the compatibility
3678    /// facade's private storage to downstream crates.
3679    pub(crate) fn section_values(&self) -> ConfigValues {
3680        self.values.clone()
3681    }
3682
3683    pub(crate) fn from_section_parts(
3684        values: ConfigValues,
3685        memory: Option<MemoryConfig>,
3686        subagents: SubagentsConfig,
3687        providers: ProviderConfigs,
3688    ) -> Self {
3689        Self::from_parts(values, memory, subagents, providers)
3690    }
3691
3692    /// Compatibility accessor for independently persisted memory settings.
3693    pub fn memory(&self) -> &Option<MemoryConfig> {
3694        &self.memory.0
3695    }
3696
3697    pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3698        &mut self.memory.0
3699    }
3700
3701    /// Compatibility accessor for independently persisted sub-agent settings.
3702    pub fn subagents(&self) -> &SubagentsConfig {
3703        &self.subagents.0
3704    }
3705
3706    pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3707        &mut self.subagents.0
3708    }
3709
3710    /// Compatibility accessor for independently persisted legacy providers.
3711    pub fn providers(&self) -> &ProviderConfigs {
3712        &self.providers.0
3713    }
3714
3715    pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3716        &mut self.providers.0
3717    }
3718
3719    /// Canonicalize only the durable provider view when instance routing is
3720    /// authoritative. Unknown provider entries remain available for forward
3721    /// compatibility.
3722    pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3723        if self
3724            .default_provider_instance
3725            .as_ref()
3726            .is_some_and(|id| self.provider_instances.contains_key(id))
3727        {
3728            self.providers.0.clear_legacy_builtin_aliases();
3729        }
3730    }
3731
3732    /// Build the legacy full-config JSON view used by in-memory patching APIs.
3733    ///
3734    /// Public serde and patch/dot-path callers retain the historical full
3735    /// configuration shape. This value is never the persistence representation:
3736    /// [`Config::save_to_dir`] explicitly writes a root-only DTO plus sidecars.
3737    pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3738        let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3739        let object = value
3740            .as_object_mut()
3741            .expect("ConfigRoot always serializes as a JSON object");
3742        object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3743        object.insert(
3744            "subagents".to_string(),
3745            serde_json::to_value(self.subagents())?,
3746        );
3747        object.insert(
3748            "providers".to_string(),
3749            serde_json::to_value(self.providers())?,
3750        );
3751        Ok(value)
3752    }
3753
3754    /// Load configuration from file with environment variable overrides
3755    ///
3756    /// Configuration loading order:
3757    /// 1. Try loading from `config.json` (`{data_dir}/config.json`)
3758    /// 2. Use defaults
3759    /// 3. Apply environment variable overrides (highest priority)
3760    ///
3761    /// # Environment Variables
3762    ///
3763    /// - `BAMBOO_PORT`: Override server port
3764    /// - `BAMBOO_BIND`: Override bind address
3765    /// - `BAMBOO_DATA_DIR`: Override data directory
3766    /// - `BAMBOO_PROVIDER`: Override default provider
3767    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
3768    /// - `BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION`: Override project durable-memory index prompt injection
3769    /// - `BAMBOO_MEMORY_RELEVANT_RECALL`: Override relevant durable-memory recall prompt injection
3770    /// - `BAMBOO_MEMORY_RELEVANT_RECALL_RERANK`: Override model-based relevant recall reranking
3771    /// - `BAMBOO_MEMORY_PROJECT_FIRST_DREAM`: Override project-first Dream prompt behavior
3772    pub fn new() -> Self {
3773        Self::from_data_dir(None)
3774    }
3775
3776    fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3777        let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3778            Ok(facade) => facade.effective_config(),
3779            Err(error) => {
3780                tracing::warn!(
3781                    error = %error,
3782                    "completed modular configuration is unavailable; refusing legacy-root fallback"
3783                );
3784                Self::create_default()
3785            }
3786        };
3787
3788        if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3789            tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3790            config.proxy_auth = None;
3791        }
3792        if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3793            tracing::warn!(error = %error, "provider credential hydration unavailable");
3794        }
3795        if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3796            tracing::warn!(error = %error, "MCP credential hydration unavailable");
3797        }
3798        if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3799            tracing::warn!(error = %error, "env credential hydration unavailable");
3800            for entry in &mut config.env_vars {
3801                if entry.secret {
3802                    entry.value.clear();
3803                }
3804            }
3805        }
3806        if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3807            tracing::warn!(error = %error, "cluster credential hydration unavailable");
3808            config.clear_cluster_runtime_credentials();
3809        }
3810        if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3811            tracing::warn!(error = %error, "notification credential hydration unavailable");
3812            config.notifications.ntfy.token = None;
3813            config.notifications.bark.device_key = None;
3814        }
3815        if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3816            tracing::warn!(error = %error, "connect credential hydration unavailable");
3817            for platform in &mut config.connect.platforms {
3818                platform.token = None;
3819                platform.app_secret = None;
3820            }
3821        }
3822        if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3823            tracing::warn!(error = %error, "access-control credential hydration unavailable");
3824            config.clear_access_control_runtime_verifiers();
3825        }
3826        if let Some(broker) = config.subagents_mut().broker.as_mut() {
3827            if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3828                tracing::warn!(error = %error, "external broker credential hydration unavailable");
3829                broker.token.clear();
3830            }
3831        }
3832        config.normalize_tool_settings();
3833        config.normalize_skill_settings();
3834        config.normalize_plugin_trust_settings();
3835        config.extra.remove("data_dir");
3836        if apply_env {
3837            config.apply_env_overrides();
3838        }
3839        if publish {
3840            config.publish_env_vars();
3841        }
3842        config
3843    }
3844
3845    /// Load configuration from a specific data directory.
3846    ///
3847    /// Use [`Config::from_data_dir`] (publishes env vars to the global cache, for
3848    /// the context that OWNS the cache — the server bootstrap) or
3849    /// [`Config::from_data_dir_without_publish`] (for non-owning readers that must
3850    /// not clobber the live cache). #40.
3851    ///
3852    /// * `data_dir` - Optional data directory path. If None, uses default (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3853    fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3854        // Determine data_dir early (needed to find config file)
3855        let data_dir = data_dir
3856            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3857            .unwrap_or_else(default_data_dir);
3858
3859        match crate::modular_authority_boundary_present(&data_dir) {
3860            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3861            Ok(false) => {}
3862            Err(error) => {
3863                tracing::warn!(
3864                    error = %error,
3865                    "modular configuration marker is unavailable; refusing legacy-root fallback"
3866                );
3867                let mut config = Self::create_default();
3868                if apply_env {
3869                    config.apply_env_overrides();
3870                }
3871                if publish {
3872                    config.publish_env_vars();
3873                }
3874                return config;
3875            }
3876        }
3877
3878        // Finish any shared manifest-committed credential extraction before
3879        // reading even one member of the transaction, then plan only the
3880        // provider/MCP/root domains. The optional broker has its own planner so
3881        // malformed broker metadata cannot suppress main configuration loading.
3882        let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3883            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3884            .map_err(|error| {
3885                tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3886                error
3887            })
3888            .is_ok();
3889        let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3890            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3891            .map_err(|error| {
3892                tracing::warn!(error = %error, "cluster credential migration unavailable");
3893                error
3894            })
3895            .is_ok();
3896
3897        // A recovery performed by either legacy credential planner may have
3898        // completed the modular split. Reclassify immediately before touching
3899        // config.json so a pending split cannot race this compatibility load
3900        // back into legacy authority.
3901        match crate::modular_authority_boundary_present(&data_dir) {
3902            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3903            Ok(false) => {}
3904            Err(error) => {
3905                tracing::warn!(
3906                    error = %error,
3907                    "modular configuration boundary is unavailable; refusing legacy-root fallback"
3908                );
3909                let mut config = Self::create_default();
3910                if apply_env {
3911                    config.apply_env_overrides();
3912                }
3913                if publish {
3914                    config.publish_env_vars();
3915                }
3916                return config;
3917            }
3918        }
3919
3920        let config_path = data_dir.join("config.json");
3921
3922        let mut config = if config_path.exists() {
3923            if let Ok(content) = std::fs::read_to_string(&config_path) {
3924                Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3925                    // Don't silently discard the user's config on corruption.
3926                    // Quarantine the unparseable file, then recover the MOST recent
3927                    // intent in order: (1) SALVAGE the still-valid fields from the
3928                    // corrupt file (a single bad field shouldn't drop everything),
3929                    // (2) the last-known-good config.json.bak, (3) defaults.
3930                    // #37 / #135. Tag the recovered config with a
3931                    // ConfigRecoveryStatus (unconfirmed) so save_to_dir refuses to
3932                    // overwrite config.json until the caller confirms — the
3933                    // quarantined original stays hand-recoverable until then. #153.
3934                    tracing::warn!(
3935                        "Failed to parse config.json ({}); quarantining it and attempting recovery",
3936                        e
3937                    );
3938                    let quarantine_path = quarantine_corrupt_config(&config_path);
3939                    let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3940                        .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3941                        .or_else(|| {
3942                            Self::load_backup(&data_dir).map(|(cfg, generation)| {
3943                                (cfg, ConfigRecoverySource::Backup { generation })
3944                            })
3945                        })
3946                        .unwrap_or_else(|| {
3947                            tracing::warn!(
3948                                "Could not salvage and no usable config.json.bak; using defaults"
3949                            );
3950                            (Self::create_default(), ConfigRecoverySource::Defaults)
3951                        });
3952                    recovered.recovery_status = Some(ConfigRecoveryStatus {
3953                        source,
3954                        quarantine_path,
3955                        confirmed: false,
3956                    });
3957                    recovered
3958                })
3959            } else {
3960                Self::create_default()
3961            }
3962        } else {
3963            Self::create_default()
3964        };
3965
3966        // Phase-1 registrar migration: an existing sidecar is authoritative;
3967        // when absent, retain the legacy inline value loaded from config.json.
3968        // A malformed sidecar is never rewritten during load and the inline
3969        // value remains available, preventing a bad independent edit from
3970        // erasing the user's last usable configuration.
3971        let mut memory_module = config.memory.clone();
3972        match memory_module.load_sync(&data_dir) {
3973            Ok(true) => config.memory = memory_module,
3974            Ok(false) => {}
3975            Err(error) => tracing::warn!(
3976                "Failed to load memory.json; using legacy config.json memory: {error}"
3977            ),
3978        }
3979        let mut subagents_module = config.subagents.clone();
3980        match subagents_module.load_sync(&data_dir) {
3981            Ok(true) => config.subagents = subagents_module,
3982            Ok(false) => {}
3983            Err(error) => tracing::warn!(
3984                "Failed to load subagents.json; using legacy config.json subagents: {error}"
3985            ),
3986        }
3987        if provider_mcp_ready {
3988            let mut providers_module = config.providers.clone();
3989            match providers_module.load_sync(&data_dir) {
3990                Ok(true) => config.providers = providers_module,
3991                Ok(false) => {}
3992                Err(error) => tracing::warn!(
3993                    "Failed to load providers.json; using legacy config.json providers: {error}"
3994                ),
3995            }
3996        }
3997
3998        // Decrypt encrypted proxy auth into in-memory plaintext form.
3999        config.hydrate_proxy_auth_from_encrypted();
4000        if provider_mcp_ready {
4001            if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
4002                tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
4003                config.proxy_auth = None;
4004            }
4005        }
4006        // Decrypt encrypted provider API keys into in-memory plaintext form.
4007        config.hydrate_provider_api_keys_from_encrypted();
4008        // Decrypt encrypted provider-instance API keys into in-memory plaintext form.
4009        config.hydrate_provider_instance_api_keys_from_encrypted();
4010        // Decrypt encrypted MCP secrets into in-memory plaintext form.
4011        config.hydrate_mcp_secrets_from_encrypted();
4012        if provider_mcp_ready {
4013            if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
4014                tracing::warn!(error = %error, "provider credential hydration unavailable");
4015            }
4016            if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
4017                tracing::warn!(error = %error, "MCP credential hydration unavailable");
4018            }
4019        }
4020        // Decrypt encrypted env vars into in-memory plaintext form.
4021        config.hydrate_env_vars_from_encrypted();
4022        if provider_mcp_ready {
4023            if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
4024                tracing::warn!(error = %error, "env credential hydration unavailable");
4025                for entry in &mut config.env_vars {
4026                    if entry.secret {
4027                        entry.value.clear();
4028                    }
4029                }
4030            }
4031        }
4032        // Cluster migration is deliberately independent from provider/MCP
4033        // readiness. A malformed optional fabric fails only cluster runtime
4034        // authentication, while missing/corrupt refs never fall back to legacy
4035        // ciphertext or an unauthenticated SSH attempt.
4036        if cluster_ready {
4037            if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
4038                tracing::warn!(error = %error, "cluster credential hydration unavailable");
4039                config.clear_cluster_runtime_credentials();
4040            }
4041        } else {
4042            config.clear_cluster_runtime_credentials();
4043        }
4044        // Decrypt the encrypted broker token into in-memory plaintext.
4045        config.hydrate_broker_token_from_encrypted();
4046        // Decrypt encrypted notification-channel secrets into in-memory plaintext.
4047        config.hydrate_notifications_from_encrypted();
4048        if provider_mcp_ready {
4049            if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
4050                tracing::warn!(error = %error, "notification credential hydration unavailable");
4051                config.notifications.ntfy.token = None;
4052                config.notifications.bark.device_key = None;
4053            }
4054        } else {
4055            // A pending or unreadable credential migration means the isolated
4056            // store is not authoritative yet. Legacy plaintext/ciphertext may
4057            // remain on disk for recovery, but notification sinks must not use
4058            // it in this process.
4059            config.notifications.ntfy.token = None;
4060            config.notifications.bark.device_key = None;
4061        }
4062        // Merge the standalone connect.json (#455) onto `config.connect`,
4063        // migrating a legacy inline `connect` key from config.json (#453
4064        // state) when present. MUST run before the token hydration below so
4065        // it decrypts the post-merge ciphertext, not a stale/legacy copy.
4066        config.merge_connect_config(&data_dir);
4067        // One-time (idempotent) sweep of the rotated config.json.bak[.N]
4068        // generations for a legacy embedded `connect` sub-tree left behind by
4069        // a pre-#455 build (#468, follow-up to #457). Independent of whether
4070        // `merge_connect_config` just migrated the CURRENT config.json above —
4071        // an instance that was already migrated by an earlier run of this
4072        // binary has a clean config.json today but may still carry the
4073        // legacy key in an untouched `.bak`/`.bak.1`/`.bak.2` generation, since
4074        // backup rotation only overwrites those on a fresh SAVE. Runs on every
4075        // load but is cheap and a no-op once every generation has been swept.
4076        scrub_legacy_connect_from_config_backups(&data_dir);
4077        // Decrypt encrypted bamboo-connect platform tokens into in-memory plaintext.
4078        config.hydrate_connect_platform_tokens_from_encrypted();
4079        if provider_mcp_ready {
4080            if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
4081                tracing::warn!(error = %error, "connect credential hydration unavailable");
4082                for platform in &mut config.connect.platforms {
4083                    platform.token = None;
4084                    platform.app_secret = None;
4085                }
4086            }
4087        }
4088        if provider_mcp_ready {
4089            if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
4090                tracing::warn!(error = %error, "access-control credential hydration unavailable");
4091                config.clear_access_control_runtime_verifiers();
4092            }
4093        } else {
4094            config.clear_access_control_runtime_verifiers();
4095        }
4096        config.normalize_tool_settings();
4097        config.normalize_skill_settings();
4098        config.normalize_plugin_trust_settings();
4099
4100        // Legacy: `data_dir` is no longer a persisted config field. The data directory is
4101        // derived from runtime (BAMBOO_DATA_DIR or `${HOME}/.bamboo`).
4102        config.extra.remove("data_dir");
4103
4104        // Apply environment variable overrides (highest priority). Skipped by
4105        // one-shot CLI writers (`bamboo init` / `config set`) so transient
4106        // `BAMBOO_*` values are never baked into the persisted config.json.
4107        if apply_env {
4108            config.apply_env_overrides();
4109        }
4110
4111        // Publish env vars to the global cache so Bash tools can inject them —
4112        // ONLY when the caller owns that cache. Non-owning readers pass
4113        // publish=false so they don't clobber the server's live env-var cache.
4114        if publish {
4115            config.publish_env_vars();
4116        }
4117
4118        config
4119    }
4120
4121    /// Apply `BAMBOO_*` environment overrides (highest priority) onto a loaded
4122    /// config. Factored out so one-shot writers can skip it (see
4123    /// [`Config::from_data_dir_without_env`]).
4124    fn apply_env_overrides(&mut self) {
4125        if let Ok(port) = std::env::var("BAMBOO_PORT") {
4126            if let Ok(port) = port.parse() {
4127                self.server.port = port;
4128            }
4129        }
4130
4131        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
4132            self.server.bind = bind;
4133        }
4134
4135        // Note: BAMBOO_DATA_DIR already handled by the caller. In instance
4136        // mode the override may name either an exact instance id or a provider
4137        // type. Type matches are ordered by instance id so the result is
4138        // deterministic even for a multi-account configuration.
4139        if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
4140            let provider = provider.trim().to_string();
4141            if !provider.is_empty() {
4142                self.provider = provider.clone();
4143                if !self.provider_instances.is_empty() {
4144                    let selected = self
4145                        .provider_instances
4146                        .contains_key(&provider)
4147                        .then_some(provider.clone())
4148                        .or_else(|| {
4149                            let mut matching = self
4150                                .provider_instances
4151                                .iter()
4152                                .filter(|(_, instance)| {
4153                                    instance.enabled && instance.provider_type == provider
4154                                })
4155                                .map(|(id, _)| id.clone())
4156                                .collect::<Vec<_>>();
4157                            matching.sort();
4158                            matching.into_iter().next()
4159                        });
4160                    self.default_provider_instance = selected;
4161                }
4162            }
4163        }
4164
4165        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
4166            self.headless_auth = parse_bool_env(&headless);
4167        }
4168
4169        if let Ok(project_prompt_injection) =
4170            std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
4171        {
4172            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4173            memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
4174        }
4175
4176        if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
4177            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4178            memory.relevant_recall = parse_bool_env(&relevant_recall);
4179        }
4180
4181        if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4182            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4183            memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4184        }
4185
4186        if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4187            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4188            memory.project_first_dream = parse_bool_env(&project_first_dream);
4189        }
4190
4191        // Per-provider API keys from the environment (highest priority). Lets a
4192        // 12-factor / secret-manager / --env-file / k8s-Secret deploy supply the
4193        // key at runtime instead of baking a plaintext `api_key` into a mounted
4194        // config.json. The `api_key_from_env` flag keeps `refresh_provider_api_keys_encrypted`
4195        // from re-encrypting these keys into `api_key_encrypted` on a later save,
4196        // so an env key is never persisted to disk. (#253)
4197        if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4198            let key = key.trim();
4199            if !key.is_empty() {
4200                if self.provider_instances.is_empty() {
4201                    let openai = self
4202                        .providers
4203                        .openai
4204                        .get_or_insert_with(OpenAIConfig::default);
4205                    openai.api_key = key.to_string();
4206                    openai.api_key_from_env = true;
4207                } else {
4208                    self.apply_provider_instance_env_key("openai", key);
4209                }
4210            }
4211        }
4212        if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4213            let key = key.trim();
4214            if !key.is_empty() {
4215                if self.provider_instances.is_empty() {
4216                    let anthropic = self
4217                        .providers
4218                        .anthropic
4219                        .get_or_insert_with(AnthropicConfig::default);
4220                    anthropic.api_key = key.to_string();
4221                    anthropic.api_key_from_env = true;
4222                } else {
4223                    self.apply_provider_instance_env_key("anthropic", key);
4224                }
4225            }
4226        }
4227        if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4228            let key = key.trim();
4229            if !key.is_empty() {
4230                if self.provider_instances.is_empty() {
4231                    let gemini = self
4232                        .providers
4233                        .gemini
4234                        .get_or_insert_with(GeminiConfig::default);
4235                    gemini.api_key = key.to_string();
4236                    gemini.api_key_from_env = true;
4237                } else {
4238                    self.apply_provider_instance_env_key("gemini", key);
4239                }
4240            }
4241        }
4242    }
4243
4244    fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4245        for instance in self.provider_instances.values_mut().filter(|instance| {
4246            instance.provider_type == provider_type
4247                && crate::provider_instance_api_key_from_env(instance)
4248        }) {
4249            instance.api_key = key.to_string();
4250            instance.api_key_encrypted = None;
4251        }
4252    }
4253
4254    /// Apply runtime-only `BAMBOO_*` overrides to a config assembled by the
4255    /// modular facade. Facade callers use this after durable section snapshots
4256    /// and credential references have been materialized; one-shot writers keep
4257    /// using the no-env load path so overrides are never persisted.
4258    pub fn apply_runtime_env_overrides(&mut self) {
4259        self.apply_env_overrides();
4260    }
4261
4262    /// Load config from disk AND publish its env vars to the process-global cache
4263    /// (so Bash tools inject them). For the context that OWNS that cache — the
4264    /// server bootstrap. Library / secondary readers that only need to read a
4265    /// value must use [`Config::from_data_dir_without_publish`] instead, or they
4266    /// will clobber the server's live cache with stale disk data (#38 / #40).
4267    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4268        Self::from_data_dir_impl(data_dir, true, true)
4269    }
4270
4271    /// Load config from disk WITHOUT publishing env vars to the global cache.
4272    /// For non-owning readers (e.g. permission storage) that just need a config
4273    /// value and must not clobber the live env-var cache. #40.
4274    pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4275        Self::from_data_dir_impl(data_dir, false, true)
4276    }
4277
4278    /// Load config from disk WITHOUT applying `BAMBOO_*` env-var overrides and
4279    /// WITHOUT publishing to the global cache. For one-shot CLI writers
4280    /// (`bamboo init` / `config set`) that immediately re-save: applying env
4281    /// overrides here would bake transient values (port/bind/provider/memory
4282    /// flags) permanently into config.json. Same corruption-recovery + default
4283    /// fallback as the normal load.
4284    pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4285        Self::from_data_dir_impl(data_dir, false, false)
4286    }
4287
4288    /// Merge the standalone `connect.json` (#455) onto `self.connect`, the
4289    /// load-side counterpart of [`save_connect_config`]. Called once per load,
4290    /// BEFORE [`Config::hydrate_connect_platform_tokens_from_encrypted`] runs,
4291    /// so hydration decrypts the POST-merge ciphertext rather than a stale
4292    /// copy still embedded in `config.json`.
4293    ///
4294    /// - `connect.json` present & parseable: authoritative — OVERWRITES
4295    ///   whatever `self.connect` currently holds. If `self.connect` was ALSO
4296    ///   non-empty (a legacy inline `connect` key still in config.json, e.g.
4297    ///   #453-era state, or written by an older binary), that's a stale
4298    ///   duplicate: log a warning and proactively strip the superseded key
4299    ///   from config.json now (#457) rather than waiting for the next
4300    ///   natural save — cheap, and consistent with not spreading token
4301    ///   ciphertext across files.
4302    /// - `connect.json` present but corrupt/unparsable: fail SAFE for this
4303    ///   security-sensitive feature. Log an error, quarantine the bad file to
4304    ///   `connect.json.bak` (best-effort), and continue with an EMPTY
4305    ///   `ConnectConfig` — never falls back to a legacy config.json copy.
4306    /// - `connect.json` absent & `self.connect` non-empty (pure legacy
4307    ///   state): migrate proactively. Adopt the legacy value (already parsed
4308    ///   into `self`) and persist it: strip the `connect` key from
4309    ///   config.json and write connect.json (#457 — NOT a full
4310    ///   [`Config::save_to_dir`], which would re-encrypt every OTHER secret
4311    ///   in config.json and rotate its backups as a load-time side effect,
4312    ///   even for a read-only command like `bamboo config get`), logged at
4313    ///   info.
4314    /// - `connect.json` absent & `self.connect` empty: nothing to do.
4315    fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4316        let connect_path = data_dir.join("connect.json");
4317        match std::fs::read_to_string(&connect_path) {
4318            Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4319                Ok(connect) => {
4320                    let legacy_key_present = !connect_config_is_empty(&self.connect);
4321                    if legacy_key_present {
4322                        tracing::warn!(
4323                            "config.json still has a legacy `connect` key alongside \
4324                             connect.json; connect.json takes precedence — dropping the \
4325                             stale key from config.json now"
4326                        );
4327                    }
4328                    self.connect = connect;
4329                    if legacy_key_present {
4330                        strip_legacy_connect_key_from_config_json(data_dir);
4331                    }
4332                }
4333                Err(e) => {
4334                    tracing::error!(
4335                        "Failed to parse {:?} ({}); continuing with an empty (inert) \
4336                         connect config instead of falling back to a legacy config.json copy",
4337                        connect_path,
4338                        e
4339                    );
4340                    quarantine_corrupt_connect(&connect_path);
4341                    self.connect = ConnectConfig::default();
4342                }
4343            },
4344            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4345                if !connect_config_is_empty(&self.connect) {
4346                    tracing::info!(
4347                        "Migrating legacy `connect` config from config.json to a \
4348                         standalone connect.json"
4349                    );
4350                    // Narrow migration write (#457): strip only the `connect` key
4351                    // from config.json and write connect.json directly, instead of
4352                    // routing through a full `save_to_dir` (see doc comment above).
4353                    strip_legacy_connect_key_from_config_json(data_dir);
4354                    if let Err(e) = save_connect_config(&self.connect, data_dir) {
4355                        tracing::error!("Failed to write connect.json during migration: {}", e);
4356                    }
4357                }
4358            }
4359            Err(e) => {
4360                tracing::error!(
4361                    "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4362                    connect_path,
4363                    e
4364                );
4365                self.connect = ConnectConfig::default();
4366            }
4367        }
4368    }
4369
4370    /// Deserialize config JSON and run the in-memory hydration + normalization
4371    /// chain. Shared by the primary load and the backup-recovery path (#37).
4372    fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4373        serde_json::from_str::<Config>(content).map(|mut config| {
4374            config.hydrate_proxy_auth_from_encrypted();
4375            config.hydrate_provider_api_keys_from_encrypted();
4376            config.hydrate_provider_instance_api_keys_from_encrypted();
4377            config.hydrate_mcp_secrets_from_encrypted();
4378            config.hydrate_env_vars_from_encrypted();
4379            config.hydrate_cluster_fabric_from_encrypted();
4380            config.hydrate_broker_token_from_encrypted();
4381            config.hydrate_notifications_from_encrypted();
4382            config.hydrate_connect_platform_tokens_from_encrypted();
4383            config.normalize_tool_settings();
4384            config.normalize_skill_settings();
4385            config
4386        })
4387    }
4388
4389    /// Try to recover from the rotated `config.json.bak[.N]` generations (each a
4390    /// last-known-good written before a save) when the primary `config.json` is
4391    /// corrupt. Walks newest -> oldest and returns the first that parses (paired
4392    /// with its generation index, 0 == `.bak`, for [`ConfigRecoverySource::Backup`]);
4393    /// `None` if every generation is missing or also unparseable. #37 / #135.
4394    fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4395        let config_path = data_dir.join("config.json");
4396        for gen in 0..BAK_GENERATIONS {
4397            let backup = backup_path_for(&config_path, gen);
4398            let Ok(content) = std::fs::read_to_string(&backup) else {
4399                continue;
4400            };
4401            match Self::parse_and_hydrate(&content) {
4402                Ok(config) => {
4403                    tracing::info!("Recovered configuration from {:?}", backup);
4404                    return Some((config, gen));
4405                }
4406                Err(e) => {
4407                    tracing::warn!(
4408                        "Backup {:?} is unparseable ({}); trying an older generation",
4409                        backup,
4410                        e
4411                    );
4412                }
4413            }
4414        }
4415        None
4416    }
4417
4418    /// Largest corrupt-object key count we'll attempt to salvage. The overlay loop
4419    /// is O(keys) full-Config deserializes over a growing object (the `extra`
4420    /// catch-all absorbs unknown keys), i.e. O(n²) on a pathological file; cap it
4421    /// so a junk-key-flooded config.json can't stall a load. A real config has a
4422    /// few dozen top-level keys, so this only ever trips on garbage.
4423    const SALVAGE_MAX_KEYS: usize = 512;
4424
4425    /// Best-effort PARTIAL salvage of a corrupt `config.json` (#135): parse it as a
4426    /// generic JSON object and overlay each top-level field onto the richest
4427    /// known-good baseline — the last-known-good `config.json.bak` if present, else
4428    /// a fresh default — keeping only the fields that still yield a valid [`Config`].
4429    /// A single bad field (wrong type, malformed section, …) then keeps the
4430    /// baseline's value instead of discarding ALL the user's other settings.
4431    ///
4432    /// Overlaying onto `.bak` (rather than defaults) means the result is the
4433    /// best-of-both: the backup's complete recent-good state PLUS the corrupt
4434    /// file's still-valid newer edits on top — so salvage is never worse than the
4435    /// plain `.bak` fallback, removing the "sparse salvage defeats a rich backup"
4436    /// hazard. Tried BEFORE the bare `.bak` fallback.
4437    ///
4438    /// Returns the hydrated salvaged config, or `None` when the corrupt file isn't
4439    /// even a JSON object (nothing field-wise to salvage) so the caller falls
4440    /// through to `.bak` / defaults.
4441    ///
4442    /// NOTE: the per-field overlay guarantees a VALID `Config`, not a *maximal* or
4443    /// attribution-perfect one. Deterministic alphabetical key order (serde_json is
4444    /// BTreeMap-backed, no `preserve_order`) means a rename/alias pair like
4445    /// `mcp`/`mcpServers` can drop the second-seen even if it'd be valid alone — the
4446    /// outcome is still a valid config, just not necessarily the richest possible.
4447    ///
4448    /// Returns the hydrated salvaged config paired with the top-level keys that
4449    /// were actually recovered from the corrupt document (used to populate
4450    /// [`ConfigRecoverySource::Salvaged`]).
4451    fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4452        // Must at least be a JSON object; otherwise there's nothing field-wise to
4453        // salvage (a truncated/garbage file just falls through to .bak/defaults).
4454        let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4455        let corrupt_obj = corrupt.as_object()?;
4456        if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4457            tracing::warn!(
4458                "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4459                corrupt_obj.len(),
4460                Self::SALVAGE_MAX_KEYS
4461            );
4462            return None;
4463        }
4464
4465        // Overlay onto the richest known-good baseline: the last-known-good backup
4466        // if it parses, else a fresh default. This makes salvage >= the plain .bak
4467        // fallback in every case.
4468        let mut base = Self::load_backup(data_dir)
4469            .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4470            .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4471        let base_obj = base.as_object_mut()?;
4472
4473        let mut salvaged: Vec<String> = Vec::new();
4474        for (key, value) in corrupt_obj {
4475            let previous = base_obj.insert(key.clone(), value.clone());
4476            // Keep the field iff the WHOLE config still deserializes with it
4477            // overlaid — base is valid before each step, so a failure isolates THIS
4478            // field as the corrupt one (and inter-field constraints are respected).
4479            if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4480                salvaged.push(key.clone());
4481            } else {
4482                match previous {
4483                    Some(prev) => {
4484                        base_obj.insert(key.clone(), prev);
4485                    }
4486                    None => {
4487                        base_obj.remove(key);
4488                    }
4489                }
4490            }
4491        }
4492
4493        tracing::warn!(
4494            "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4495             last-known-good/default value",
4496            salvaged.len(),
4497            salvaged.join(", ")
4498        );
4499
4500        // Re-serialize the rebuilt (all-valid) object and run it back through the
4501        // normal parse+hydrate path so secret-decryption / normalization match a
4502        // clean load exactly.
4503        let rebuilt = serde_json::to_string(&base).ok()?;
4504        Self::parse_and_hydrate(&rebuilt)
4505            .ok()
4506            .map(|config| (config, salvaged))
4507    }
4508
4509    /// Get the effective default model for the currently active provider.
4510    ///
4511    /// When `features.provider_model_ref` is enabled, reads from `defaults.chat`
4512    /// before falling back to legacy provider-specific config.
4513    ///
4514    /// Note: for most providers this is a required config value (returns None when absent).
4515    /// Copilot has a built-in fallback when no model is configured.
4516    pub fn get_model(&self) -> Option<String> {
4517        if self.features.provider_model_ref {
4518            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4519                return Some(model_ref.model.clone());
4520            }
4521        }
4522        let provider = self.effective_default_provider();
4523        if let Some(instance) = self.provider_instances.get(provider) {
4524            return instance
4525                .model
4526                .clone()
4527                .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4528        }
4529        match provider {
4530            "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4531            "anthropic" => self
4532                .providers
4533                .anthropic
4534                .as_ref()
4535                .and_then(|c| c.model.clone()),
4536            "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4537            "copilot" => Some(
4538                self.providers
4539                    .copilot
4540                    .as_ref()
4541                    .and_then(|c| c.model.clone())
4542                    .unwrap_or_else(|| "gpt-4o".to_string()),
4543            ),
4544            _ => None,
4545        }
4546    }
4547
4548    /// Get the fast/cheap model for the currently active provider.
4549    ///
4550    /// When `features.provider_model_ref` is enabled, reads from `defaults.fast`
4551    /// before falling back to legacy provider-specific config.
4552    ///
4553    /// Used for lightweight tasks like title generation and summarization.
4554    /// Falls back to `get_model()` when no fast_model is configured.
4555    pub fn get_fast_model(&self) -> Option<String> {
4556        if self.features.provider_model_ref {
4557            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4558                return Some(model_ref.model.clone());
4559            }
4560        }
4561        let provider = self.effective_default_provider();
4562        let fast = if let Some(instance) = self.provider_instances.get(provider) {
4563            instance.fast_model.clone()
4564        } else {
4565            match provider {
4566                "openai" => self
4567                    .providers
4568                    .openai
4569                    .as_ref()
4570                    .and_then(|c| c.fast_model.clone()),
4571                "anthropic" => self
4572                    .providers
4573                    .anthropic
4574                    .as_ref()
4575                    .and_then(|c| c.fast_model.clone()),
4576                "gemini" => self
4577                    .providers
4578                    .gemini
4579                    .as_ref()
4580                    .and_then(|c| c.fast_model.clone()),
4581                "copilot" => self
4582                    .providers
4583                    .copilot
4584                    .as_ref()
4585                    .and_then(|c| c.fast_model.clone()),
4586                _ => None,
4587            }
4588        };
4589        fast.or_else(|| self.get_model())
4590    }
4591
4592    /// Get the configured task summarization model.
4593    ///
4594    /// When `features.provider_model_ref` is enabled, reads from
4595    /// `defaults.task_summary` before falling back through
4596    /// `defaults.fast` → `defaults.chat`.
4597    ///
4598    /// This is used for conversation/task summarization and context compression.
4599    pub fn get_task_summary_model(&self) -> Option<String> {
4600        if self.features.provider_model_ref {
4601            if let Some(model_ref) = self
4602                .defaults
4603                .as_ref()
4604                .and_then(|d| d.task_summary.as_ref())
4605                .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4606                .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4607            {
4608                return Some(model_ref.model.clone());
4609            }
4610        }
4611
4612        self.get_fast_model()
4613    }
4614
4615    /// Get the configured memory/background summarization model.
4616    ///
4617    /// When `features.provider_model_ref` is enabled, reads from
4618    /// `defaults.memory_background` before falling back through
4619    /// `defaults.fast` → `defaults.chat`.
4620    ///
4621    /// Legacy configuration follows the same dedicated → fast → chat order.
4622    pub fn get_memory_background_model(&self) -> Option<String> {
4623        if self.features.provider_model_ref {
4624            if let Some(model_ref) = self
4625                .defaults
4626                .as_ref()
4627                .and_then(|d| d.memory_background.as_ref())
4628            {
4629                return Some(model_ref.model.clone());
4630            }
4631            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4632                return Some(model_ref.model.clone());
4633            }
4634            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4635                return Some(model_ref.model.clone());
4636            }
4637        }
4638        let configured = self
4639            .memory
4640            .as_ref()
4641            .and_then(|memory| memory.background_model.as_ref())
4642            .map(|value| value.trim())
4643            .filter(|value| !value.is_empty())
4644            .map(ToString::to_string);
4645        configured.or_else(|| self.get_fast_model())
4646    }
4647
4648    /// Resolve the configured default work area path when present.
4649    ///
4650    /// This validates that the configured directory exists, but intentionally
4651    /// returns the stable expanded path rather than the platform-specific
4652    /// canonicalized path. On macOS, `canonicalize()` may rewrite `/var/...`
4653    /// to `/private/var/...`, which is correct at the filesystem layer but
4654    /// undesirable as a user-facing/config-derived workspace path.
4655    pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4656        let raw = self
4657            .default_work_area
4658            .as_ref()
4659            .and_then(|config| config.path.as_ref())
4660            .map(|value| value.trim())
4661            .filter(|value| !value.is_empty())?;
4662
4663        let candidate = expand_user_path(raw);
4664        if candidate.is_absolute() {
4665            let canonical = std::fs::canonicalize(&candidate).ok();
4666            return canonical
4667                .as_ref()
4668                .filter(|path| path.is_dir())
4669                .map(|_| candidate.clone())
4670                .or_else(|| candidate.is_dir().then_some(candidate));
4671        }
4672
4673        let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4674        let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4675        canonical
4676            .as_ref()
4677            .filter(|path| path.is_dir())
4678            .map(|_| from_bamboo_dir.clone())
4679            .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4680            .or_else(|| candidate.is_dir().then_some(candidate))
4681    }
4682
4683    /// Get the vision-capable model for the currently active provider.
4684    ///
4685    /// Used for image understanding tasks.
4686    /// Falls back to the fast model, then the chat model, when no vision model
4687    /// is configured.
4688    pub fn get_vision_model(&self) -> Option<String> {
4689        if self.features.provider_model_ref {
4690            if let Some(model_ref) = self
4691                .defaults
4692                .as_ref()
4693                .and_then(|defaults| defaults.vision.as_ref())
4694                .or_else(|| {
4695                    self.defaults
4696                        .as_ref()
4697                        .and_then(|defaults| defaults.fast.as_ref())
4698                })
4699                .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4700            {
4701                return Some(model_ref.model.clone());
4702            }
4703        }
4704        let provider = self.effective_default_provider();
4705        let vision = if let Some(instance) = self.provider_instances.get(provider) {
4706            instance.vision_model.clone()
4707        } else {
4708            match provider {
4709                "openai" => self
4710                    .providers
4711                    .openai
4712                    .as_ref()
4713                    .and_then(|c| c.vision_model.clone()),
4714                "anthropic" => self
4715                    .providers
4716                    .anthropic
4717                    .as_ref()
4718                    .and_then(|c| c.vision_model.clone()),
4719                "gemini" => self
4720                    .providers
4721                    .gemini
4722                    .as_ref()
4723                    .and_then(|c| c.vision_model.clone()),
4724                "copilot" => self
4725                    .providers
4726                    .copilot
4727                    .as_ref()
4728                    .and_then(|c| c.vision_model.clone()),
4729                _ => None,
4730            }
4731        };
4732        vision.or_else(|| self.get_fast_model())
4733    }
4734
4735    /// Get the default reasoning effort for the chat role.
4736    ///
4737    /// A role-specific preference wins over the provider-instance default.
4738    pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4739        if self.features.provider_model_ref {
4740            if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4741                return chat
4742                    .reasoning_effort
4743                    .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4744            }
4745        }
4746        self.reasoning_effort_for_key(self.effective_default_provider())
4747    }
4748
4749    /// Resolve the configured default reasoning effort for a provider routing key.
4750    ///
4751    /// The key may be a multi-instance provider id (for example `"copilot-work"`)
4752    /// or a legacy provider type (for example `"openai"`). In multi-instance mode
4753    /// the per-instance `reasoning_effort` lives under `provider_instances[<id>]`,
4754    /// so we resolve instance ids there first; otherwise we fall back to the
4755    /// legacy per-provider config. Both the execute path
4756    /// ([`crate`]'s `get_reasoning_effort_for_provider`) and the session-create
4757    /// path ([`Self::get_reasoning_effort`]) delegate here so the two cannot drift.
4758    pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4759        let trimmed = key.trim();
4760        if trimmed.is_empty() {
4761            return None;
4762        }
4763
4764        // Multi-instance mode: the routing key is an instance id.
4765        if let Some(instance) = self.provider_instances.get(trimmed) {
4766            return instance.reasoning_effort;
4767        }
4768
4769        // Legacy mode: the routing key is a provider type.
4770        match trimmed {
4771            "openai" => self
4772                .providers
4773                .openai
4774                .as_ref()
4775                .and_then(|c| c.reasoning_effort),
4776            "anthropic" => self
4777                .providers
4778                .anthropic
4779                .as_ref()
4780                .and_then(|c| c.reasoning_effort),
4781            "gemini" => self
4782                .providers
4783                .gemini
4784                .as_ref()
4785                .and_then(|c| c.reasoning_effort),
4786            "copilot" => self
4787                .providers
4788                .copilot
4789                .as_ref()
4790                .and_then(|c| c.reasoning_effort),
4791            "bodhi" => self
4792                .providers
4793                .bodhi
4794                .as_ref()
4795                .and_then(|c| c.reasoning_effort),
4796            _ => None,
4797        }
4798    }
4799
4800    /// Get exact disabled tool references for catalog-aware resolution.
4801    ///
4802    /// References remain exact here: catalog-aware filtering resolves an exact
4803    /// registered name before applying legacy/builtin alias fallback. Eagerly
4804    /// rewriting `apply_patch` to `Edit`, for example, would make an exact
4805    /// custom `apply_patch` registration indistinguishable from the builtin.
4806    pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4807        self.tools
4808            .disabled
4809            .iter()
4810            .map(|name| name.trim())
4811            .filter(|name| !name.is_empty())
4812            .map(str::to_string)
4813            .collect()
4814    }
4815
4816    /// Legacy normalized-name facade retained for source compatibility.
4817    ///
4818    /// New execution/catalog code must use [`Self::disabled_tool_references`]
4819    /// so an exact registered alias can be resolved before fallback.
4820    pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4821        self.disabled_tool_references()
4822            .into_iter()
4823            .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4824            .collect()
4825    }
4826
4827    /// Normalize tool settings (trim / dedupe / sort).
4828    pub fn normalize_tool_settings(&mut self) {
4829        self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4830    }
4831
4832    /// Get normalized disabled skill IDs.
4833    pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4834        self.skills
4835            .disabled
4836            .iter()
4837            .map(|id| id.trim())
4838            .filter(|id| !id.is_empty())
4839            .map(|id| id.to_string())
4840            .collect()
4841    }
4842
4843    /// Normalize skill settings (trim / dedupe / sort).
4844    pub fn normalize_skill_settings(&mut self) {
4845        self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4846    }
4847
4848    /// Normalize `plugin_trust.trusted_hosts` entries (trim / lowercase / drop
4849    /// empties) so a hand-edited `config.json` doesn't silently accumulate
4850    /// mixed-case or whitespace-padded entries. [`is_host_trusted`] itself
4851    /// already matches case-insensitively regardless of how an entry is
4852    /// stored, so this is defense in depth / a canonical on-disk form, not
4853    /// the source of the security fix — that's the host/path-component
4854    /// matching in [`is_host_trusted`] itself.
4855    pub fn normalize_plugin_trust_settings(&mut self) {
4856        self.plugin_trust.trusted_hosts = self
4857            .plugin_trust
4858            .trusted_hosts
4859            .iter()
4860            .map(|entry| entry.trim().to_ascii_lowercase())
4861            .filter(|entry| !entry.is_empty())
4862            .collect();
4863    }
4864
4865    /// Return the effective default provider key.
4866    ///
4867    /// Prefers `default_provider_instance` when set; falls back to the
4868    /// legacy `provider` string.
4869    pub fn effective_default_provider(&self) -> &str {
4870        self.default_provider_instance
4871            .as_deref()
4872            .unwrap_or(&self.provider)
4873    }
4874
4875    /// Whether provider instances are configured (new multi-instance path).
4876    pub fn has_provider_instances(&self) -> bool {
4877        !self.provider_instances.is_empty()
4878    }
4879
4880    /// Build a flat map of all env vars with non-empty values (for process injection).
4881    pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4882        self.env_vars
4883            .iter()
4884            .filter(|e| !e.value.trim().is_empty())
4885            .map(|e| (e.name.clone(), e.value.clone()))
4886            .collect()
4887    }
4888
4889    fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4890        self.env_vars
4891            .iter()
4892            .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4893            .map(|entry| PromptSafeEnvVarEntry {
4894                name: entry.name.clone(),
4895                secret: entry.secret,
4896                description: entry
4897                    .description
4898                    .as_ref()
4899                    .map(|value| value.trim().to_string())
4900                    .filter(|value| !value.is_empty()),
4901            })
4902            .collect()
4903    }
4904
4905    /// Update the global env vars cache (called on config load / reload).
4906    pub fn publish_env_vars(&self) {
4907        let map = self.env_vars_as_map();
4908
4909        #[cfg(any(test, feature = "test-utils"))]
4910        if crate::test_support::env_vars_cache_override_is_active() {
4911            crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4912            return;
4913        }
4914
4915        let mut env_guard = env_vars_cache().write().recover_poison();
4916        *env_guard = map;
4917
4918        let prompt_safe = self.prompt_safe_env_vars();
4919        let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4920        *prompt_guard = prompt_safe;
4921    }
4922
4923    /// Read the current env vars snapshot (called by Bash tool at process spawn time).
4924    pub fn current_env_vars() -> HashMap<String, String> {
4925        #[cfg(any(test, feature = "test-utils"))]
4926        if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4927            return env_vars;
4928        }
4929
4930        env_vars_cache().read().recover_poison().clone()
4931    }
4932
4933    /// Read the current prompt-safe env var snapshot (names + metadata only; no secret values).
4934    pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4935        #[cfg(any(test, feature = "test-utils"))]
4936        if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4937            return env_vars;
4938        }
4939
4940        prompt_safe_env_vars_cache().read().recover_poison().clone()
4941    }
4942
4943    /// Create a default configuration without loading from file
4944    fn create_default() -> Self {
4945        Self::from_parts(
4946            ConfigValues {
4947                http_proxy: String::new(),
4948                https_proxy: String::new(),
4949                proxy_auth: None,
4950                proxy_auth_encrypted: None,
4951                proxy_auth_credential_ref: None,
4952                headless_auth: false,
4953                run_budget: RunBudgetConfig::default(),
4954                stream_timeout: StreamTimeoutConfig::default(),
4955                llm_retry: None,
4956                context_management: ContextManagementConfig::default(),
4957                cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4958                provider: default_provider(),
4959                provider_instances: HashMap::new(),
4960                default_provider_instance: None,
4961                server: ServerConfig::default(),
4962                keyword_masking: KeywordMaskingConfig::default(),
4963                anthropic_model_mapping: AnthropicModelMapping::default(),
4964                gemini_model_mapping: GeminiModelMapping::default(),
4965                patrol_model: None,
4966                hooks: HooksConfig::default(),
4967                lifecycle_hooks: LifecycleHooksConfig::default(),
4968                tools: ToolsConfig::default(),
4969                skills: SkillsConfig::default(),
4970                env_vars: Vec::new(),
4971                default_work_area: None,
4972                worktrees: WorktreeSettings::default(),
4973                access_control: None,
4974                features: FeatureFlags::default(),
4975                defaults: None,
4976                mcp: bamboo_domain::mcp_config::McpConfig::default(),
4977                notifications: NotificationsConfig::default(),
4978                connect: ConnectConfig::default(),
4979                plugin_trust: PluginTrustConfig::default(),
4980                extra: BTreeMap::new(),
4981            },
4982            None,
4983            SubagentsConfig::default(),
4984            ProviderConfigs::default(),
4985        )
4986    }
4987
4988    /// Get the full server address (bind:port)
4989    pub fn server_addr(&self) -> String {
4990        format!("{}:{}", self.server.bind, self.server.port)
4991    }
4992
4993    /// Save configuration to disk
4994    pub fn save(&self) -> Result<()> {
4995        self.save_to_dir(default_data_dir())
4996    }
4997
4998    /// Persist only the memory module, leaving every other config file untouched.
4999    /// Legacy layouts only; modular layouts require a revisioned section update.
5000    pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5001        self.memory.save_sync(data_dir)
5002    }
5003
5004    /// Persist only the sub-agent module, leaving every other config file untouched.
5005    /// Legacy layouts only; modular layouts require a revisioned section update.
5006    pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5007        self.subagents.save_sync(data_dir)
5008    }
5009
5010    /// Persist only provider configuration. Provider plaintext keys are first
5011    /// refreshed into their encrypted at-rest representation.
5012    /// Legacy layouts only; modular layouts require a revisioned section update.
5013    pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5014        crate::config_module::ensure_legacy_module_layout(data_dir)?;
5015        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5016        let mut config = self.clone();
5017        config.clear_legacy_provider_aliases_for_instance_mode();
5018        config.refresh_provider_api_keys_encrypted()?;
5019        config.providers.save_sync(data_dir)
5020    }
5021
5022    /// Build the metadata-only documents used by a provider credential
5023    /// transaction. Nothing is written here: the migration journal owns the
5024    /// durable commit and publishes both documents together with credentials.
5025    pub(crate) fn prepare_provider_transaction_documents(
5026        &self,
5027        provider_document: &[u8],
5028    ) -> Result<(Vec<u8>, Vec<u8>)> {
5029        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5030        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5031            anyhow::bail!(
5032                "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
5033                status.source
5034            );
5035        }
5036
5037        let mut to_save = self.clone();
5038        to_save.clear_legacy_provider_aliases_for_instance_mode();
5039        to_save.extra.remove("data_dir");
5040        to_save.extra.remove("model");
5041        to_save.refresh_encrypted_secrets()?;
5042        to_save.ensure_provider_instance_credentials_isolated()?;
5043        to_save.sanitize_mcp_credential_refs_for_disk();
5044        to_save.sanitize_env_vars_for_disk();
5045        to_save.sanitize_notifications_for_disk();
5046        to_save.sanitize_cluster_fabric_for_disk();
5047        to_save.assign_connect_platform_ids();
5048        to_save.normalize_tool_settings();
5049        to_save.normalize_skill_settings();
5050
5051        let mut root = durable_root_value(to_save.values.clone())
5052            .context("Failed to serialize root config DTO to JSON")?;
5053        if let Some(object) = root.as_object_mut() {
5054            object.remove("connect");
5055        }
5056        let root = serde_json::to_vec_pretty(&root)?;
5057
5058        let mut providers = to_save.providers.0.clone();
5059        macro_rules! sanitize_provider {
5060            ($field:ident) => {
5061                if let Some(provider) = providers.$field.as_mut() {
5062                    if !provider.api_key.trim().is_empty()
5063                        && !provider.api_key_from_env
5064                        && provider.credential_ref.is_none()
5065                    {
5066                        anyhow::bail!(
5067                            "provider secret requires credential transaction before persistence"
5068                        );
5069                    }
5070                    provider.api_key_encrypted = None;
5071                }
5072            };
5073        }
5074        sanitize_provider!(openai);
5075        sanitize_provider!(anthropic);
5076        sanitize_provider!(gemini);
5077        if let Some(provider) = providers.bodhi.as_mut() {
5078            if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
5079                anyhow::bail!("provider secret requires credential transaction before persistence");
5080            }
5081            provider.api_key_encrypted = None;
5082        }
5083
5084        let existing_provider_value = if provider_document.is_empty() {
5085            None
5086        } else {
5087            Some(
5088                serde_json::from_slice::<Value>(provider_document)
5089                    .context("provider metadata document is invalid")?,
5090            )
5091        };
5092        let provider_value = match existing_provider_value {
5093            Some(Value::Object(mut envelope))
5094                if envelope.contains_key("schema_version")
5095                    || envelope.contains_key("revision")
5096                    || envelope.contains_key("data") =>
5097            {
5098                let revision = envelope
5099                    .get("revision")
5100                    .and_then(Value::as_u64)
5101                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5102                let schema_version = envelope
5103                    .get("schema_version")
5104                    .and_then(Value::as_u64)
5105                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5106                if schema_version != 1 || !envelope.contains_key("data") {
5107                    anyhow::bail!("provider revision envelope is unsupported");
5108                }
5109                let revision = revision
5110                    .checked_add(1)
5111                    .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
5112                envelope.insert("revision".into(), Value::from(revision));
5113                envelope.insert("data".into(), serde_json::to_value(providers)?);
5114                Value::Object(envelope)
5115            }
5116            Some(_) | None => serde_json::to_value(providers)?,
5117        };
5118        Ok((root, serde_json::to_vec_pretty(&provider_value)?))
5119    }
5120
5121    /// The pending config-corruption recovery, if `config.json` failed to
5122    /// parse on load and the recovery hasn't been confirmed yet. `None` on
5123    /// every clean load. #153.
5124    pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
5125        self.recovery_status.as_ref()
5126    }
5127
5128    /// Confirm a pending recovery, allowing the next [`Config::save`] /
5129    /// [`Config::save_to_dir`] to overwrite the quarantined-corrupt
5130    /// `config.json` with this recovered state. No-op if there's no pending
5131    /// recovery. Prefer [`Config::confirm_recovery_and_save_to_dir`], which
5132    /// also persists and clears the flag in one step. #153.
5133    pub fn confirm_recovery(&mut self) {
5134        if let Some(status) = self.recovery_status.as_mut() {
5135            status.confirmed = true;
5136        }
5137    }
5138
5139    /// Confirm a pending recovery AND persist it in one step: marks it
5140    /// confirmed (satisfying the [`Config::save_to_dir`] guard), writes the
5141    /// recovered state to `config.json`, then clears `recovery_status`
5142    /// entirely — once this succeeds the config is no longer "pending
5143    /// confirmation", it's just the normal on-disk config. Errors (and
5144    /// leaves `recovery_status` untouched) if there's nothing pending, or if
5145    /// the save itself fails. #153.
5146    pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
5147        if self.recovery_status.is_none() {
5148            anyhow::bail!("No pending config-corruption recovery to confirm");
5149        }
5150        self.confirm_recovery();
5151        self.save_to_dir(data_dir)?;
5152        self.recovery_status = None;
5153        Ok(())
5154    }
5155
5156    /// Assign a stable [`ConnectPlatformConfig::id`] to every `connect.platforms`
5157    /// entry that doesn't already have one (#496).
5158    ///
5159    /// Migration-on-write: [`Config::save_to_dir`] always calls this on its
5160    /// internal save-copy before persisting, so every path that writes
5161    /// `connect.json` gets ids backfilled. Callers that mutate the *live*
5162    /// in-memory config as part of a save (e.g. the server's settings-PATCH
5163    /// handler) should also call this directly on that in-memory value
5164    /// before responding, so a client that echoes the response straight
5165    /// back round-trips the id immediately rather than only after the next
5166    /// reload/restart. Never called from load — a config that's never saved
5167    /// again (e.g. one sitting in an unconfirmed-recovery state, see #493)
5168    /// is never rewritten just to backfill ids. An entry that already has
5169    /// an id keeps it unchanged; ids are never reassigned or deduplicated
5170    /// once set.
5171    pub fn assign_connect_platform_ids(&mut self) {
5172        for platform in &mut self.connect.platforms {
5173            if platform.id.is_none() {
5174                platform.id = Some(uuid::Uuid::new_v4().to_string());
5175            }
5176        }
5177    }
5178
5179    /// Save configuration to disk under the provided data directory.
5180    ///
5181    /// Root configuration is stored as `{data_dir}/config.json`; extracted
5182    /// memory, sub-agent, and provider modules are stored in sibling sidecars.
5183    ///
5184    /// Refuses to write when this config carries an unconfirmed
5185    /// [`ConfigRecoveryStatus`] (#153) — i.e. it was recovered from a corrupt
5186    /// `config.json` and the recovery hasn't been confirmed — so a corrupt
5187    /// original a user might want to hand-fix is never silently clobbered by
5188    /// an auto-persisted recovery. Call [`Config::confirm_recovery`] (or
5189    /// [`Config::confirm_recovery_and_save_to_dir`]) first.
5190    pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5191        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5192        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5193            anyhow::bail!(
5194                "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5195                 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5196                 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5197                status.source,
5198                status.quarantine_path,
5199            );
5200        }
5201        if self.proxy_auth_credential_ref.is_none()
5202            && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5203        {
5204            anyhow::bail!(
5205                "proxy auth requires the isolated credential transaction before persistence"
5206            );
5207        }
5208        if self.env_vars.iter().any(|entry| {
5209            entry.secret
5210                && entry.credential_ref.is_none()
5211                && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5212        }) {
5213            anyhow::bail!(
5214                "secret env vars require the isolated credential transaction before persistence"
5215            );
5216        }
5217        if [
5218            (
5219                &self.notifications.ntfy.token,
5220                &self.notifications.ntfy.token_encrypted,
5221                &self.notifications.ntfy.credential_ref,
5222            ),
5223            (
5224                &self.notifications.bark.device_key,
5225                &self.notifications.bark.device_key_encrypted,
5226                &self.notifications.bark.credential_ref,
5227            ),
5228        ]
5229        .iter()
5230        .any(|(plaintext, ciphertext, reference)| {
5231            reference.is_none()
5232                && (plaintext
5233                    .as_deref()
5234                    .is_some_and(|value| !value.trim().is_empty())
5235                    || ciphertext.is_some())
5236        }) {
5237            anyhow::bail!(
5238                "notification secrets require the isolated credential transaction before persistence"
5239            );
5240        }
5241
5242        if crate::modular_authority_boundary_present(&data_dir)
5243            .context("Failed to inspect modular configuration authority boundary")?
5244        {
5245            crate::ConfigFacade::open_or_migrate(&data_dir)
5246                .context("Failed to recover modular configuration before persistence")?;
5247            crate::persist_facade_effective_config(&data_dir, self)
5248                .context("Failed to persist modular configuration sections")?;
5249            return Ok(());
5250        }
5251
5252        if crate::section_layout_is_active(&data_dir)
5253            .context("Failed to inspect modular configuration layout")?
5254        {
5255            crate::persist_facade_effective_config(&data_dir, self)
5256                .context("Failed to persist modular configuration sections")?;
5257            return Ok(());
5258        }
5259
5260        let path = data_dir.join("config.json");
5261
5262        if let Some(parent) = path.parent() {
5263            std::fs::create_dir_all(parent)
5264                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5265        }
5266
5267        let mut to_save = self.clone();
5268        to_save.clear_legacy_provider_aliases_for_instance_mode();
5269        // Never persist `data_dir` into config.json (data dir is runtime-derived).
5270        to_save.extra.remove("data_dir");
5271        // Root-level `model` is deprecated; do not persist it.
5272        to_save.extra.remove("model");
5273        // `subagents.broker` is `#[serde(skip)]` (runtime-only, lives in its own
5274        // broker.json / embedded in-process) — nothing to encrypt or persist here.
5275        to_save.refresh_encrypted_secrets()?;
5276        to_save.ensure_provider_instance_credentials_isolated()?;
5277        to_save.sanitize_mcp_credential_refs_for_disk();
5278        to_save.sanitize_env_vars_for_disk();
5279        to_save.sanitize_notifications_for_disk();
5280        to_save.sanitize_cluster_fabric_for_disk();
5281        to_save.assign_connect_platform_ids();
5282        to_save.normalize_tool_settings();
5283        to_save.normalize_skill_settings();
5284
5285        // Split `connect` (#455) out of the config.json document: bamboo-connect
5286        // platform-bridge credentials (bot tokens, allowlists) get their own
5287        // sibling file, connect.json (written below), instead of living in
5288        // config.json — different sensitivity/lifecycle. The `connect` FIELD on
5289        // `Config` keeps its normal serde shape unchanged (still required by the
5290        // settings API / `preserve_masked_connect_secrets`, which operate on the
5291        // in-memory struct) — only the serialized DOCUMENT that becomes
5292        // config.json's bytes has the key stripped, and that's done on the
5293        // `serde_json::Value` here, not via `#[serde(skip)]` on the field.
5294        let mut config_value = durable_root_value(to_save.values.clone())
5295            .context("Failed to serialize root config DTO to JSON")?;
5296        if let Some(obj) = config_value.as_object_mut() {
5297            obj.remove("connect");
5298        }
5299        let content = serde_json::to_string_pretty(&config_value)
5300            .context("Failed to serialize config to JSON")?;
5301
5302        // Persist extracted modules before stripping their legacy inline
5303        // representation from config.json. If the process crashes or the root
5304        // rewrite fails during the first migration, the next load can still use
5305        // either the new sidecars or the untouched inline values. Do this before
5306        // rotating root backups so a sidecar error cannot consume backup history
5307        // for a root document that was never rewritten.
5308        to_save.memory.save_sync(&data_dir)?;
5309        to_save.subagents.save_sync(&data_dir)?;
5310        to_save.providers.save_sync(&data_dir)?;
5311
5312        // Back up the current on-disk config (last-known-good) before overwriting,
5313        // so corruption (a bad/partial write, external edit, disk issue) stays
5314        // recoverable via config.json.bak on the next load. Best-effort. Only
5315        // refresh the backup from a PARSEABLE config.json — otherwise a save right
5316        // after an in-memory recovery (where the on-disk config.json is still the
5317        // corrupt original) would clobber the good .bak with garbage. #37.
5318        if path.exists()
5319            && std::fs::read_to_string(&path)
5320                .ok()
5321                .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5322        {
5323            // Rotate the older generations down (.bak -> .bak.1 -> .bak.2 …) so a
5324            // few last-known-good snapshots survive, then snapshot the current
5325            // (parseable) config.json as the freshest .bak. #135.
5326            rotate_backups(&path, BAK_GENERATIONS);
5327            let backup = backup_path_for(&path, 0);
5328            let backup_result = std::fs::read(&path).and_then(|bytes| {
5329                let mut value: Value = serde_json::from_slice(&bytes)
5330                    .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5331                if sanitize_ref_backed_mcp_json(&mut value) {
5332                    let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5333                        std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5334                    })?;
5335                    write_atomic(&backup, &sanitized)
5336                } else {
5337                    std::fs::copy(&path, &backup).map(|_| ())
5338                }
5339            });
5340            if let Err(e) = backup_result {
5341                tracing::warn!("Failed to back up config.json before save: {}", e);
5342            }
5343            scrub_ref_backed_mcp_from_config_backups(&path);
5344        }
5345
5346        write_atomic(&path, content.as_bytes())
5347            .with_context(|| format!("Failed to write config file: {:?}", path))?;
5348
5349        save_connect_config(&to_save.connect, &data_dir)?;
5350
5351        Ok(())
5352    }
5353}
5354
5355/// Remove credential-ref-backed MCP values from a raw root document without
5356/// otherwise normalizing its compatibility shape. This is used for rotated
5357/// root backups as well as the typed disk DTO so a pre-fix root cannot keep a
5358/// duplicate secret alive for several more saves.
5359fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5360    let Some(object) = root.as_object_mut() else {
5361        return false;
5362    };
5363    let Some(mcp) = (if object.contains_key("mcpServers") {
5364        object.get_mut("mcpServers")
5365    } else {
5366        object.get_mut("mcp")
5367    }) else {
5368        return false;
5369    };
5370    let mut changed = false;
5371    if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5372        for server in servers {
5373            if let Some(object) = server.as_object_mut() {
5374                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5375                {
5376                    changed |= sanitize_ref_backed_mcp_transport(transport);
5377                }
5378            }
5379        }
5380    } else if let Some(servers) = mcp.as_object_mut() {
5381        for server in servers.values_mut() {
5382            if let Some(object) = server.as_object_mut() {
5383                changed |= sanitize_ref_backed_mcp_transport(object);
5384                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5385                {
5386                    changed |= sanitize_ref_backed_mcp_transport(transport);
5387                }
5388            }
5389        }
5390    }
5391    changed
5392}
5393
5394fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5395    let mut changed = false;
5396    let env_names = object
5397        .get("env_credential_refs")
5398        .and_then(Value::as_object)
5399        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5400        .unwrap_or_default();
5401    for name in env_names {
5402        for field in ["env", "env_encrypted"] {
5403            if object
5404                .get_mut(field)
5405                .and_then(Value::as_object_mut)
5406                .and_then(|values| values.remove(&name))
5407                .is_some()
5408            {
5409                changed = true;
5410            }
5411        }
5412    }
5413    let header_names = object
5414        .get("header_credential_refs")
5415        .and_then(Value::as_object)
5416        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5417        .unwrap_or_default();
5418    for name in header_names {
5419        for field in ["headers", "headers_encrypted"] {
5420            if object
5421                .get_mut(field)
5422                .and_then(Value::as_object_mut)
5423                .and_then(|values| values.remove(&name))
5424                .is_some()
5425            {
5426                changed = true;
5427            }
5428        }
5429    }
5430    if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5431        for header in headers {
5432            let Some(header) = header.as_object_mut() else {
5433                continue;
5434            };
5435            if header.get("credential_ref").is_some_and(Value::is_string) {
5436                changed |= header.remove("value").is_some();
5437                changed |= header.remove("value_encrypted").is_some();
5438            }
5439        }
5440    }
5441    changed
5442}
5443
5444fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5445    for generation in 0..BAK_GENERATIONS {
5446        let backup = backup_path_for(path, generation);
5447        let Ok(bytes) = std::fs::read(&backup) else {
5448            continue;
5449        };
5450        let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5451            continue;
5452        };
5453        if sanitize_ref_backed_mcp_json(&mut value) {
5454            if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5455                if let Err(error) = write_atomic(&backup, &sanitized) {
5456                    tracing::warn!(
5457                        "Failed to scrub MCP credentials from {:?}: {}",
5458                        backup,
5459                        error
5460                    );
5461                }
5462            }
5463        }
5464    }
5465}
5466
5467/// Persist `connect` (#455) to its own sibling file, `connect.json`, next to
5468/// config.json — the save-side counterpart of [`Config::merge_connect_config`].
5469///
5470/// Only writes when the config is non-empty OR the file already exists, so a
5471/// fresh/default install with no platforms configured never gets a
5472/// `connect.json` littering its data dir. Before an existing file is
5473/// overwritten, it's copied aside to a single `connect.json.bak` generation
5474/// (best-effort) — connect.json doesn't need config.json's multi-generation
5475/// rotation, one last-known-good snapshot is enough.
5476fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5477    let path = data_dir.join("connect.json");
5478    if connect_config_is_empty(connect) && !path.exists() {
5479        return Ok(());
5480    }
5481
5482    if path.exists() {
5483        let backup = path.with_extension("json.bak");
5484        if let Err(e) = std::fs::copy(&path, &backup) {
5485            tracing::warn!("Failed to back up connect.json before save: {}", e);
5486        }
5487    }
5488
5489    let content = serde_json::to_string_pretty(connect)
5490        .context("Failed to serialize connect config to JSON")?;
5491    write_atomic(&path, content.as_bytes())
5492        .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5493    Ok(())
5494}
5495
5496/// Remove the legacy inline `connect` key from `config.json` on disk, if
5497/// present — the narrow, load-side counterpart of the full-document rewrite
5498/// [`Config::save_to_dir`] would otherwise perform just to drop one stale
5499/// key. Used by [`Config::merge_connect_config`] both when adopting a
5500/// pure-legacy `connect` key (migration) and when a stale legacy key lingers
5501/// alongside an authoritative connect.json. #457.
5502///
5503/// Operates on the raw `serde_json::Value` read straight from disk — NOT on
5504/// the typed `Config` — so it touches nothing but the one key: no other
5505/// secret gets re-encrypted, and no `config.json.bak` generation gets
5506/// rotated, as a side effect of a load.
5507///
5508/// Best-effort: read/parse/write failures are logged, not propagated — this
5509/// runs as a side effect of `Config::new()` / load, which has no `Result` to
5510/// surface it through. A failure here just leaves the stale key in place
5511/// until the next natural save; connect.json (written separately) is already
5512/// authoritative in memory either way.
5513fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5514    let config_path = data_dir.join("config.json");
5515    let content = match std::fs::read_to_string(&config_path) {
5516        Ok(content) => content,
5517        Err(e) => {
5518            tracing::error!(
5519                "Failed to read config.json to strip legacy `connect` key: {}",
5520                e
5521            );
5522            return;
5523        }
5524    };
5525    let mut value: serde_json::Value = match serde_json::from_str(&content) {
5526        Ok(value) => value,
5527        Err(e) => {
5528            tracing::error!(
5529                "Failed to parse config.json to strip legacy `connect` key: {}",
5530                e
5531            );
5532            return;
5533        }
5534    };
5535    let Some(obj) = value.as_object_mut() else {
5536        return;
5537    };
5538    if obj.remove("connect").is_none() {
5539        // Nothing to strip (e.g. raced with a concurrent save that already
5540        // dropped it) — avoid an unnecessary rewrite.
5541        return;
5542    }
5543    let rewritten = match serde_json::to_string_pretty(&value) {
5544        Ok(rewritten) => rewritten,
5545        Err(e) => {
5546            tracing::error!(
5547                "Failed to serialize config.json after stripping legacy `connect` key: {}",
5548                e
5549            );
5550            return;
5551        }
5552    };
5553    if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5554        tracing::error!(
5555            "Failed to write config.json after stripping legacy `connect` key: {}",
5556            e
5557        );
5558    }
5559}
5560
5561/// Sweep the rotated `config.json.bak[.N]` generations for a legacy embedded
5562/// `connect` sub-tree that predates the #455 connect.json split, and strip it
5563/// in place. #468 (follow-up to #457).
5564///
5565/// `strip_legacy_connect_key_from_config_json` only ever rewrites the CURRENT
5566/// `config.json` — it never reaches into `.bak` generations, and the normal
5567/// backup-rotation path (see [`rotate_backups`]) only overwrites a `.bak[.N]`
5568/// slot as a side effect of a fresh SAVE. An instance that upgraded from a
5569/// pre-#455 build but rarely (or never) triggers a config save can therefore
5570/// carry the legacy, encrypted `connect` sub-tree — including bot tokens, an
5571/// immediately-usable remote-control credential — in an old backup generation
5572/// indefinitely, even after its live config.json has long since been
5573/// migrated.
5574///
5575/// Deliberately surgical, mirroring the `.bak` files' role as the user's
5576/// recovery net (#493's "backups are a low-sensitivity snapshot, don't fuss
5577/// with them" posture):
5578/// - a generation that doesn't exist, or that fails to parse as JSON, is
5579///   SKIPPED — logged, never deleted, never guessed at. Corrupt/foreign
5580///   content in a `.bak` slot is left exactly as found for hand inspection.
5581/// - a generation that parses but carries no `connect` key (the overwhelming
5582///   majority, especially on any instance that predates this fix by more
5583///   than `BAK_GENERATIONS` saves) is left COMPLETELY untouched — not even a
5584///   byte-identical rewrite — so its mtime and on-disk bytes survive.
5585/// - only a generation that actually parses AND carries the legacy key gets
5586///   rewritten, via the same key-removal-on-the-raw-`Value` + `write_atomic`
5587///   approach as `strip_legacy_connect_key_from_config_json`, so every other
5588///   byte of that snapshot (all other settings, formatting aside) survives.
5589///
5590/// Runs unconditionally on every load (not gated on the CURRENT config.json
5591/// still carrying the legacy key) specifically to catch already-migrated
5592/// installs whose backups predate this fix. Cheap: at most `BAK_GENERATIONS`
5593/// small file reads, and a genuine no-op (zero writes) once every generation
5594/// has been swept once. Best-effort like its sibling: failures are logged,
5595/// not propagated, since this runs as a side effect of `Config::new()` /
5596/// load, which has no `Result` to surface it through.
5597fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5598    let config_path = data_dir.join("config.json");
5599    for gen in 0..BAK_GENERATIONS {
5600        let backup = backup_path_for(&config_path, gen);
5601        let content = match std::fs::read_to_string(&backup) {
5602            Ok(content) => content,
5603            Err(e) => {
5604                if e.kind() != std::io::ErrorKind::NotFound {
5605                    tracing::warn!(
5606                        "Failed to read {:?} while scanning for legacy connect data ({}); \
5607                         leaving it untouched",
5608                        backup,
5609                        e
5610                    );
5611                }
5612                continue;
5613            }
5614        };
5615        let mut value: serde_json::Value = match serde_json::from_str(&content) {
5616            Ok(value) => value,
5617            Err(e) => {
5618                tracing::warn!(
5619                    "Skipping unparsable backup {:?} while scanning for legacy connect data \
5620                     ({}); left untouched (never deleted)",
5621                    backup,
5622                    e
5623                );
5624                continue;
5625            }
5626        };
5627        let Some(obj) = value.as_object_mut() else {
5628            // Not a JSON object (e.g. `null`/an array) — nothing to strip, and
5629            // not a shape we should try to rewrite. Leave it alone.
5630            continue;
5631        };
5632        if obj.remove("connect").is_none() {
5633            // No legacy key in this generation — skip without writing so the
5634            // file's bytes/mtime are left completely untouched.
5635            continue;
5636        }
5637        let rewritten = match serde_json::to_string_pretty(&value) {
5638            Ok(rewritten) => rewritten,
5639            Err(e) => {
5640                tracing::error!(
5641                    "Failed to serialize {:?} after stripping legacy connect data: {}",
5642                    backup,
5643                    e
5644                );
5645                continue;
5646            }
5647        };
5648        match write_atomic(&backup, rewritten.as_bytes()) {
5649            Ok(()) => tracing::info!(
5650                "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5651                backup
5652            ),
5653            Err(e) => tracing::error!(
5654                "Failed to write {:?} after stripping legacy connect data: {}",
5655                backup,
5656                e
5657            ),
5658        }
5659    }
5660}
5661
5662/// Quarantine an unparsable `connect.json` to a single `connect.json.bak`
5663/// generation (best-effort) so the bad content survives for inspection
5664/// instead of being silently discarded. Unlike config.json's timestamped,
5665/// N-generation quarantine, connect.json only needs one slot — it's a much
5666/// smaller, less complex document and this is a fail-SAFE (empty/inert
5667/// bridge), not a fail-recover, posture. #455.
5668///
5669/// MOVES the corrupt file rather than copying it (#457): a copy would leave
5670/// the same corrupt `connect.json` sitting in the data dir right next to its
5671/// own quarantine copy, which reads as confusing/ambiguous mid-incident
5672/// (which one is live?). `rename` is used first (atomic, no partial-copy
5673/// window); if that fails — e.g. `connect.json.bak` and the data dir are on
5674/// different filesystems — fall back to copy-then-remove so the corrupt
5675/// original still doesn't linger.
5676fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5677    let backup = connect_path.with_extension("json.bak");
5678    match std::fs::rename(connect_path, &backup) {
5679        Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5680        Err(e) => {
5681            tracing::warn!(
5682                "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5683                backup,
5684                e
5685            );
5686            if let Err(e) = std::fs::copy(connect_path, &backup) {
5687                tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5688                return;
5689            }
5690            if let Err(e) = std::fs::remove_file(connect_path) {
5691                tracing::error!(
5692                    "Quarantined corrupt connect.json to {:?} but failed to remove the \
5693                     original {:?}: {}",
5694                    backup,
5695                    connect_path,
5696                    e
5697                );
5698            }
5699        }
5700    }
5701}
5702
5703/// How many `config.json.corrupted.*` quarantine files to keep. Each corrupt load
5704/// drops one; without a cap they accumulate unbounded. Newest `N` are retained.
5705const QUARANTINE_KEEP: usize = 5;
5706
5707/// Copy a corrupt config file aside to `config.json.corrupted.<nanos>` so the
5708/// user's (unparseable) configuration is preserved for inspection/recovery
5709/// instead of being silently discarded and then overwritten by defaults. #37.
5710///
5711/// Returns the quarantine path on success, so the caller can attach it to a
5712/// [`ConfigRecoveryStatus`] (#153); `None` if even the copy failed (the
5713/// corrupt original is still left in place at `config_path` regardless, since
5714/// this only ever copies, never moves/deletes).
5715fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5716    let nanos = std::time::SystemTime::now()
5717        .duration_since(std::time::UNIX_EPOCH)
5718        .map(|d| d.as_nanos())
5719        .unwrap_or(0);
5720    // Two corrupt loads in the same nanosecond would land on the same name and the
5721    // second `copy` would silently overwrite the first. Append a counter on
5722    // collision so each quarantine is preserved distinctly. #135.
5723    let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5724    let mut dedup = 1u32;
5725    while quarantine.exists() {
5726        quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5727        dedup += 1;
5728    }
5729    let result = match std::fs::copy(config_path, &quarantine) {
5730        Ok(_) => {
5731            tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5732            Some(quarantine)
5733        }
5734        Err(e) => {
5735            tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5736            None
5737        }
5738    };
5739    prune_quarantine_files(config_path, QUARANTINE_KEEP);
5740    result
5741}
5742
5743/// Keep only the newest `keep` `config.json.corrupted.*` files next to
5744/// `config_path`, deleting older ones so quarantines don't grow unbounded. #135.
5745fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5746    let Some(dir) = config_path.parent() else {
5747        return;
5748    };
5749    let prefix = "config.json.corrupted.";
5750    let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5751        Ok(entries) => entries
5752            .filter_map(|e| e.ok())
5753            .map(|e| e.path())
5754            .filter(|p| {
5755                p.file_name()
5756                    .and_then(|n| n.to_str())
5757                    .is_some_and(|n| n.starts_with(prefix))
5758            })
5759            .collect(),
5760        Err(_) => return,
5761    };
5762    if quarantines.len() <= keep {
5763        return;
5764    }
5765    // Oldest first (by mtime; missing mtime sorts oldest so it's pruned first).
5766    quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5767    let remove = quarantines.len() - keep;
5768    for stale in quarantines.into_iter().take(remove) {
5769        if let Err(e) = std::fs::remove_file(&stale) {
5770            tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5771        }
5772    }
5773}
5774
5775/// Number of `config.json.bak[.N]` generations to retain (`.bak` + `N-1` numbered).
5776/// More generations = more recovery points if a fresher backup is itself bad. #135.
5777const BAK_GENERATIONS: usize = 3;
5778
5779/// The on-disk path of backup generation `gen` (0 == `config.json.bak`).
5780fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5781    if gen == 0 {
5782        config_path.with_extension("json.bak")
5783    } else {
5784        config_path.with_extension(format!("json.bak.{gen}"))
5785    }
5786}
5787
5788/// Shift the backup generations down before a fresh `.bak` is written:
5789/// `.bak.(N-2) -> .bak.(N-1)`, …, `.bak -> .bak.1`. The oldest is overwritten by
5790/// the shift; the caller then writes the new `.bak`. Walks the highest (oldest)
5791/// destination slot first so no rename clobbers a slot a later move still needs to
5792/// read. Best-effort. #135.
5793fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5794    for gen in (1..generations).rev() {
5795        let from = backup_path_for(config_path, gen - 1);
5796        let to = backup_path_for(config_path, gen);
5797        if from.exists() {
5798            if let Err(e) = std::fs::rename(&from, &to) {
5799                tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5800            }
5801        }
5802    }
5803}
5804
5805pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5806    crate::config_store::AtomicFileStore::new(path)
5807        .write_bytes_without_backup(content)
5808        .map_err(|error| match error {
5809            crate::config_store::ConfigStoreError::Io(error) => error,
5810            other => std::io::Error::other(other),
5811        })
5812}
5813
5814#[cfg(test)]
5815mod tests {
5816    use super::*;
5817    use std::ffi::OsString;
5818    use std::path::PathBuf;
5819    use std::sync::Mutex;
5820    use std::time::{SystemTime, UNIX_EPOCH};
5821
5822    #[test]
5823    fn tools_config_preserves_unknown_keys_across_roundtrip() {
5824        let input = serde_json::json!({
5825            "disabled": ["bash"],
5826            "plugin_runtime": {
5827                "timeout_ms": 5_000,
5828                "sandbox": true
5829            },
5830            "future_flag": "enabled"
5831        });
5832
5833        let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5834        assert_eq!(config.disabled, vec!["bash"]);
5835        assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5836        assert_eq!(config.extra["future_flag"], "enabled");
5837        assert_eq!(serde_json::to_value(config).unwrap(), input);
5838    }
5839
5840    #[test]
5841    fn tools_config_keeps_section_when_only_unknown_keys_present() {
5842        let input = serde_json::json!({
5843            "tool_extension": {
5844                "mode": "strict",
5845                "options": ["one", "two"]
5846            }
5847        });
5848        let config: Config = serde_json::from_value(serde_json::json!({
5849            "tools": input.clone()
5850        }))
5851        .unwrap();
5852
5853        assert!(config.tools.disabled.is_empty());
5854        assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5855        assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5856
5857        let temp_home = TempHome::new();
5858        config.save_to_dir(temp_home.path.clone()).unwrap();
5859        let persisted: Value =
5860            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5861                .unwrap();
5862        assert_eq!(persisted["tools"], input);
5863    }
5864
5865    #[test]
5866    fn skills_config_preserves_unknown_keys_across_roundtrip() {
5867        let input = serde_json::json!({
5868            "external_catalog": {
5869                "path": "/opt/bamboo/skills",
5870                "refresh": false
5871            },
5872            "schema_version": 2
5873        });
5874
5875        let config: Config = serde_json::from_value(serde_json::json!({
5876            "skills": input.clone()
5877        }))
5878        .unwrap();
5879        assert!(config.skills.disabled.is_empty());
5880        assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5881        assert_eq!(config.skills.extra["schema_version"], 2);
5882        assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5883
5884        let temp_home = TempHome::new();
5885        config.save_to_dir(temp_home.path.clone()).unwrap();
5886        let persisted: Value =
5887            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5888                .unwrap();
5889        assert_eq!(persisted["skills"], input);
5890    }
5891
5892    #[test]
5893    fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5894        let config: Config = serde_json::from_value(serde_json::json!({
5895            "hooks": {
5896                "image_fallback": {"enabled": true, "mode": "placeholder"}
5897            },
5898            "lifecycle_hooks": {
5899                "enabled": true,
5900                "PreToolUse": [{
5901                    "matcher": "bash|write_file",
5902                    "hooks": [{"type": "command", "command": "guard.sh"}]
5903                }],
5904                "SessionStart": [{
5905                    "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5906                }]
5907            }
5908        }))
5909        .expect("lifecycle hook config should deserialize");
5910
5911        assert!(config.lifecycle_hooks.enabled);
5912        assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5913        assert!(
5914            config.lifecycle_hooks.pre_tool_use[0].enabled,
5915            "legacy groups without an enabled flag remain active"
5916        );
5917        assert_eq!(
5918            config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5919            DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5920        );
5921        assert_eq!(
5922            config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5923            25
5924        );
5925
5926        let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5927        assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5928        assert_eq!(
5929            json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5930            "bash|write_file"
5931        );
5932        assert_eq!(
5933            json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5934            "command"
5935        );
5936        assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5937            .get("enabled")
5938            .is_none());
5939        assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5940            .get("timeout_ms")
5941            .is_none());
5942        assert!(json.get("hooks").is_some());
5943    }
5944
5945    #[test]
5946    fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5947        let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5948            "type": "script",
5949            "path": ".bamboo/hooks/check.js"
5950        }))
5951        .expect("script lifecycle hook should deserialize");
5952
5953        assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5954        assert!(matches!(
5955            handler,
5956            LifecycleHookHandler::Script {
5957                runner: LifecycleScriptRunner::Auto,
5958                ..
5959            }
5960        ));
5961        let json = serde_json::to_value(handler).expect("script hook should serialize");
5962        assert_eq!(json["type"], "script");
5963        assert!(json.get("timeout_ms").is_none());
5964        assert!(json.get("runner").is_none());
5965    }
5966
5967    #[test]
5968    fn script_runner_support_is_extension_aware() {
5969        assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5970        assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5971        assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5972        assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5973        assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5974        assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5975        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5976        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5977        assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5978        assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5979    }
5980
5981    #[test]
5982    fn script_runner_names_round_trip_through_config_json() {
5983        for (runner, name) in [
5984            (LifecycleScriptRunner::Auto, "auto"),
5985            (LifecycleScriptRunner::Node, "node"),
5986            (LifecycleScriptRunner::Bun, "bun"),
5987            (LifecycleScriptRunner::Python, "python"),
5988            (LifecycleScriptRunner::Bash, "bash"),
5989            (LifecycleScriptRunner::PowerShell, "powershell"),
5990            (LifecycleScriptRunner::Cmd, "cmd"),
5991        ] {
5992            let json = serde_json::to_value(runner).unwrap();
5993            assert_eq!(json, name);
5994            assert_eq!(
5995                serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
5996                runner
5997            );
5998        }
5999    }
6000
6001    #[test]
6002    fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
6003        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6004        assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
6005        let json = serde_json::to_value(&config).unwrap();
6006        assert!(json.get("lifecycle_hooks").is_none());
6007    }
6008
6009    #[test]
6010    fn stream_timeout_defaults_are_safe_and_back_compatible() {
6011        let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6012            .expect("legacy config without stream_timeout should load");
6013        let values = ConfigValues::from(root);
6014
6015        assert_eq!(
6016            values.stream_timeout,
6017            StreamTimeoutConfig {
6018                transport_idle_timeout_secs: 120,
6019                first_semantic_timeout_secs: 600,
6020                semantic_idle_timeout_secs: 600,
6021            }
6022        );
6023        values
6024            .stream_timeout
6025            .validate()
6026            .expect("defaults are valid");
6027    }
6028
6029    #[test]
6030    fn llm_retry_defaults_load_from_a_config_without_the_key_and_tolerate_slow_routes() {
6031        let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6032            .expect("legacy config without llm_retry should load");
6033        let values = ConfigValues::from(root);
6034        // Unset means "never configured": the environment-derived policy keeps
6035        // applying, which is what an existing deployment expects on upgrade.
6036        assert_eq!(values.llm_retry, None);
6037
6038        let defaults = LlmRetryConfig::default();
6039        assert_eq!(defaults.max_attempts, 8);
6040        assert_eq!(defaults.attempt_timeout_ms, 180_000);
6041        assert_eq!(defaults.total_budget_ms, 900_000);
6042        defaults.validate().expect("defaults are valid");
6043    }
6044
6045    #[test]
6046    fn llm_retry_rejects_out_of_range_or_inconsistent_policies() {
6047        let valid = LlmRetryConfig::default();
6048        assert!(valid.validate().is_ok());
6049        for broken in [
6050            LlmRetryConfig {
6051                max_attempts: 0,
6052                ..valid
6053            },
6054            LlmRetryConfig {
6055                max_attempts: MAX_LLM_RETRY_ATTEMPTS + 1,
6056                ..valid
6057            },
6058            LlmRetryConfig {
6059                attempt_timeout_ms: 999,
6060                ..valid
6061            },
6062            LlmRetryConfig {
6063                attempt_timeout_ms: MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS + 1,
6064                ..valid
6065            },
6066            LlmRetryConfig {
6067                total_budget_ms: 0,
6068                ..valid
6069            },
6070            LlmRetryConfig {
6071                total_budget_ms: MAX_LLM_RETRY_TOTAL_BUDGET_MS + 1,
6072                ..valid
6073            },
6074            // A base delay above the cap would never be honoured.
6075            LlmRetryConfig {
6076                base_delay_ms: 2_000,
6077                max_delay_ms: 1_000,
6078                ..valid
6079            },
6080        ] {
6081            assert!(broken.validate().is_err(), "{broken:?} must be rejected");
6082        }
6083    }
6084
6085    #[test]
6086    fn llm_retry_round_trips_through_its_section() {
6087        let policy = LlmRetryConfig {
6088            max_attempts: 12,
6089            attempt_timeout_ms: 300_000,
6090            total_budget_ms: 3_600_000,
6091            base_delay_ms: 250,
6092            max_delay_ms: 60_000,
6093        };
6094        let values = ConfigValues {
6095            llm_retry: Some(policy),
6096            ..ConfigValues::default()
6097        };
6098        let json = serde_json::to_value(&values).expect("serialize");
6099        assert_eq!(json["llm_retry"]["max_attempts"], 12);
6100        let round_tripped: ConfigValues = serde_json::from_value(json).expect("deserialize");
6101        assert_eq!(round_tripped.llm_retry, Some(policy));
6102    }
6103
6104    #[test]
6105    fn stream_timeout_round_trips_through_persistence_dto() {
6106        let values = ConfigValues {
6107            stream_timeout: StreamTimeoutConfig {
6108                transport_idle_timeout_secs: 45,
6109                first_semantic_timeout_secs: 900,
6110                semantic_idle_timeout_secs: 300,
6111            },
6112            ..ConfigValues::default()
6113        };
6114
6115        let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
6116        assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
6117        assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
6118        assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
6119
6120        let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
6121        assert_eq!(
6122            ConfigValues::from(root).stream_timeout,
6123            StreamTimeoutConfig {
6124                transport_idle_timeout_secs: 45,
6125                first_semantic_timeout_secs: 900,
6126                semantic_idle_timeout_secs: 300,
6127            }
6128        );
6129    }
6130
6131    #[test]
6132    fn context_management_defaults_to_summary_and_is_omitted() {
6133        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6134        assert_eq!(
6135            config.context_management.strategy,
6136            ContextManagementStrategy::Summary
6137        );
6138        assert_eq!(
6139            config
6140                .context_management
6141                .retrieval_window
6142                .min_recent_user_turns,
6143            3
6144        );
6145        assert_eq!(
6146            config.context_management.retrieval_target_usage_percent(),
6147            25
6148        );
6149        let json = serde_json::to_value(&config).unwrap();
6150        assert!(json.get("context_management").is_none());
6151    }
6152
6153    #[test]
6154    fn retrieval_window_context_management_round_trips() {
6155        let json = serde_json::json!({
6156            "context_management": {
6157                "strategy": "retrieval_window",
6158                "retrieval_window": {
6159                    "min_recent_user_turns": 4,
6160                    "trigger_usage_ratio": 0.82,
6161                    "target_usage_ratio": 0.61,
6162                    "history_tool_required": true,
6163                    "fallback_strategy": "summary"
6164                }
6165            }
6166        });
6167        let root: ConfigRoot = serde_json::from_value(json).unwrap();
6168        let values = ConfigValues::from(root);
6169        assert_eq!(
6170            values.context_management.strategy,
6171            ContextManagementStrategy::RetrievalWindow
6172        );
6173        assert_eq!(
6174            values.context_management.retrieval_window.fallback_strategy,
6175            ContextManagementFallbackStrategy::Summary
6176        );
6177
6178        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6179        assert_eq!(
6180            persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
6181            4
6182        );
6183        assert_eq!(
6184            persisted["context_management"]["strategy"],
6185            "retrieval_window"
6186        );
6187    }
6188
6189    #[test]
6190    fn message_summary_context_management_round_trips_without_changing_defaults() {
6191        let root: ConfigRoot = serde_json::from_value(serde_json::json!({
6192            "context_management": {
6193                "strategy": "message_summary"
6194            }
6195        }))
6196        .unwrap();
6197        let values = ConfigValues::from(root);
6198        assert_eq!(
6199            values.context_management.strategy,
6200            ContextManagementStrategy::MessageSummary
6201        );
6202        assert_eq!(
6203            values.context_management.retrieval_window,
6204            RetrievalWindowContextConfig::default()
6205        );
6206
6207        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6208        assert_eq!(
6209            persisted["context_management"]["strategy"],
6210            "message_summary"
6211        );
6212        let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
6213        assert_eq!(
6214            ConfigValues::from(reloaded).context_management.strategy,
6215            ContextManagementStrategy::MessageSummary
6216        );
6217        assert_eq!(
6218            ContextManagementConfig::default().strategy,
6219            ContextManagementStrategy::Summary
6220        );
6221    }
6222
6223    #[test]
6224    fn context_management_uses_one_explicit_policy_for_every_strategy() {
6225        for strategy in ["summary", "message_summary", "retrieval_window"] {
6226            let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6227                "strategy": strategy,
6228                "trigger_usage_ratio": 0.72,
6229                "target_usage_ratio": 0.20,
6230                "retrieval_window": {"trigger_usage_ratio": 0.9, "target_usage_ratio": 0.6}
6231            }))
6232            .unwrap();
6233            let budget =
6234                policy.apply_compression_policy(bamboo_domain::TokenBudget::for_model(200_000));
6235            assert_eq!(budget.compression_trigger_percent, 72);
6236            assert_eq!(budget.compression_target_percent, 20);
6237            let saved = serde_json::to_value(&policy).unwrap();
6238            let restored: ContextManagementConfig = serde_json::from_value(saved).unwrap();
6239            assert_eq!(restored, policy);
6240        }
6241    }
6242
6243    #[test]
6244    fn context_management_migrates_stricter_legacy_ratios_without_preserving_old_sixty_percent_target(
6245    ) {
6246        for strategy in ["summary", "message_summary", "retrieval_window"] {
6247            for (trigger, target, expected_trigger, expected_target) in
6248                [(0.80, 0.60, 0.80, 0.25), (0.65, 0.15, 0.65, 0.15)]
6249            {
6250                let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6251                    "strategy": strategy,
6252                    "retrieval_window": {"trigger_usage_ratio": trigger, "target_usage_ratio": target}
6253                })).unwrap();
6254                assert_eq!(policy.trigger_usage_ratio, expected_trigger);
6255                assert_eq!(policy.target_usage_ratio, expected_target);
6256            }
6257        }
6258    }
6259
6260    #[test]
6261    fn context_management_rejects_unrepresentable_shared_range() {
6262        for (trigger, target) in [(0.80, 0.80), (1.01, 0.25), (0.015, 0.01), (0.80, 0.005)] {
6263            let value =
6264                serde_json::json!({"trigger_usage_ratio": trigger, "target_usage_ratio": target});
6265            assert!(serde_json::from_value::<ContextManagementConfig>(value).is_err());
6266        }
6267    }
6268
6269    #[test]
6270    fn retrieval_window_context_management_rejects_unsafe_policy() {
6271        for invalid in [
6272            serde_json::json!({
6273                "strategy": "retrieval_window",
6274                "retrieval_window": {"min_recent_user_turns": 0}
6275            }),
6276            serde_json::json!({
6277                "strategy": "retrieval_window",
6278                "retrieval_window": {
6279                    "target_usage_ratio": 0.8,
6280                    "trigger_usage_ratio": 0.8
6281                }
6282            }),
6283            serde_json::json!({
6284                "strategy": "retrieval_window",
6285                "retrieval_window": {
6286                    "target_usage_ratio": 0.005,
6287                    "trigger_usage_ratio": 0.006
6288                }
6289            }),
6290            serde_json::json!({
6291                "strategy": "retrieval_window",
6292                "retrieval_window": {"history_tool_required": false}
6293            }),
6294        ] {
6295            serde_json::from_value::<ContextManagementConfig>(invalid)
6296                .expect_err("unsafe retrieval-window policy must fail closed");
6297        }
6298    }
6299
6300    #[test]
6301    fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6302        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6303            "provider_type": "openai",
6304            "enabled": true
6305        }))
6306        .unwrap();
6307        let mut config = Config::default();
6308        config.values.provider = "gemini".to_string();
6309        config
6310            .provider_instances
6311            .insert("work".to_string(), instance);
6312        config.default_provider_instance = Some("work".to_string());
6313        config.providers_mut().openai = Some(OpenAIConfig::default());
6314
6315        let json = serde_json::to_value(&config).unwrap();
6316        assert_eq!(json["provider"], "gemini");
6317        assert!(json["providers"]["openai"].is_object());
6318        assert_eq!(json["default_provider_instance"], "work");
6319
6320        let round_trip: Config = serde_json::from_value(json).unwrap();
6321        assert_eq!(round_trip.provider, "gemini");
6322        assert_eq!(
6323            round_trip.default_provider_instance.as_deref(),
6324            Some("work")
6325        );
6326        assert!(round_trip.provider_instances.contains_key("work"));
6327        assert!(round_trip.providers().openai.is_some());
6328    }
6329
6330    #[test]
6331    fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6332        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6333        let dir = tempfile::tempdir().unwrap();
6334        let mut config = Config::default();
6335        config.values.provider = "anthropic".to_string();
6336        config.provider_instances.insert(
6337            "work".to_string(),
6338            serde_json::from_value(serde_json::json!({
6339                "provider_type": "openai",
6340                "model": "gpt-instance",
6341                "enabled": true,
6342                "future_instance_metadata": "preserved"
6343            }))
6344            .unwrap(),
6345        );
6346        config.default_provider_instance = Some("work".to_string());
6347        config.features.provider_model_ref = true;
6348        config.providers_mut().openai = Some(OpenAIConfig::default());
6349        config.providers_mut().anthropic = Some(AnthropicConfig::default());
6350        config
6351            .providers_mut()
6352            .extra
6353            .insert("provider".to_string(), serde_json::json!("anthropic"));
6354        config.providers_mut().extra.insert(
6355            "future_provider".to_string(),
6356            serde_json::json!({"kept": true}),
6357        );
6358
6359        let (root_bytes, provider_bytes) =
6360            config.prepare_provider_transaction_documents(&[]).unwrap();
6361        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6362        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6363        assert!(root.get("provider").is_none());
6364        assert!(root.get("providers").is_none());
6365        assert_eq!(root["default_provider_instance"], "work");
6366        assert_eq!(
6367            root["provider_instances"]["work"]["future_instance_metadata"],
6368            "preserved"
6369        );
6370        assert!(providers.get("provider").is_none());
6371        for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6372            assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6373        }
6374        assert_eq!(providers["future_provider"]["kept"], true);
6375
6376        config.save_to_dir(dir.path().to_path_buf()).unwrap();
6377        let saved_root: Value =
6378            serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6379                .unwrap();
6380        let saved_providers: Value =
6381            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6382                .unwrap();
6383        assert!(saved_root.get("provider").is_none());
6384        assert!(saved_root.get("providers").is_none());
6385        assert_eq!(saved_root["default_provider_instance"], "work");
6386        assert!(saved_providers.get("openai").is_none());
6387        assert!(saved_providers.get("anthropic").is_none());
6388        assert!(saved_providers.get("provider").is_none());
6389        assert_eq!(saved_providers["future_provider"]["kept"], true);
6390
6391        config.providers_mut().gemini = Some(GeminiConfig::default());
6392        config.save_providers_to_dir(dir.path()).unwrap();
6393        let provider_only: Value =
6394            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6395                .unwrap();
6396        assert!(provider_only.get("gemini").is_none());
6397        assert!(provider_only.get("provider").is_none());
6398        assert_eq!(provider_only["future_provider"]["kept"], true);
6399    }
6400
6401    #[test]
6402    fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6403        let mut config = Config::default();
6404        config.values.provider = "openai".to_string();
6405        config.providers_mut().openai = Some(OpenAIConfig {
6406            model: Some("gpt-legacy".to_string()),
6407            ..OpenAIConfig::default()
6408        });
6409        config.provider_instances.insert(
6410            "work".to_string(),
6411            serde_json::from_value(serde_json::json!({
6412                "provider_type": "copilot",
6413                "enabled": true
6414            }))
6415            .unwrap(),
6416        );
6417        config.default_provider_instance = Some("openai".to_string());
6418
6419        let (root_bytes, provider_bytes) =
6420            config.prepare_provider_transaction_documents(&[]).unwrap();
6421        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6422        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6423        assert_eq!(root["provider"], "openai");
6424        assert_eq!(root["default_provider_instance"], "openai");
6425        assert!(root["provider_instances"]["work"].is_object());
6426        assert_eq!(providers["openai"]["model"], "gpt-legacy");
6427    }
6428
6429    #[test]
6430    fn persistence_keeps_legacy_provider_without_instance_default() {
6431        let values = ConfigValues {
6432            provider: "gemini".to_string(),
6433            ..ConfigValues::default()
6434        };
6435
6436        let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6437        assert_eq!(json["provider"], "gemini");
6438    }
6439
6440    #[test]
6441    fn stream_timeout_rejects_zero_and_unbounded_values() {
6442        for (field, invalid) in [
6443            ("transport_idle_timeout_secs", 0),
6444            ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6445            ("semantic_idle_timeout_secs", 0),
6446        ] {
6447            let mut timeout = serde_json::json!({});
6448            timeout[field] = serde_json::json!(invalid);
6449            let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6450                .expect_err("invalid timeout must be rejected");
6451            assert!(error.to_string().contains("stream timeout must be between"));
6452        }
6453    }
6454
6455    struct EnvVarGuard {
6456        key: &'static str,
6457        previous: Option<OsString>,
6458    }
6459
6460    impl EnvVarGuard {
6461        fn set(key: &'static str, value: &str) -> Self {
6462            let previous = std::env::var_os(key);
6463            std::env::set_var(key, value);
6464            Self { key, previous }
6465        }
6466
6467        fn unset(key: &'static str) -> Self {
6468            let previous = std::env::var_os(key);
6469            std::env::remove_var(key);
6470            Self { key, previous }
6471        }
6472    }
6473
6474    impl Drop for EnvVarGuard {
6475        fn drop(&mut self) {
6476            match &self.previous {
6477                Some(value) => std::env::set_var(self.key, value),
6478                None => std::env::remove_var(self.key),
6479            }
6480        }
6481    }
6482
6483    #[test]
6484    fn run_budget_config_merge_is_tighten_only_per_field() {
6485        let config_default = RunBudgetConfig {
6486            max_total_tokens: Some(100_000),
6487            max_tool_calls: Some(500),
6488            max_subagents: Some(10),
6489            max_rounds: Some(200),
6490        };
6491
6492        // No override at all: config default passes through unchanged.
6493        assert_eq!(
6494            config_default.merged_with_override(None),
6495            config_default,
6496            "no override falls back to the config default entirely"
6497        );
6498
6499        // Override TIGHTENS exactly one field; the others keep the config
6500        // default (per-field, not all-or-nothing).
6501        let tighten_one = RunBudgetConfig {
6502            max_total_tokens: Some(5_000),
6503            max_tool_calls: None,
6504            max_subagents: None,
6505            max_rounds: None,
6506        };
6507        let merged = config_default.merged_with_override(Some(&tighten_one));
6508        assert_eq!(merged.max_total_tokens, Some(5_000));
6509        assert_eq!(merged.max_tool_calls, Some(500));
6510        assert_eq!(merged.max_subagents, Some(10));
6511        assert_eq!(merged.max_rounds, Some(200));
6512
6513        // A LOOSER override is clamped to the config default: a client can
6514        // never raise the operator's ceiling (PR #539 review, finding #3).
6515        let loosen_attempt = RunBudgetConfig {
6516            max_total_tokens: Some(999_999_999),
6517            max_tool_calls: Some(10_000),
6518            max_subagents: Some(1_000),
6519            max_rounds: Some(100_000),
6520        };
6521        assert_eq!(
6522            config_default.merged_with_override(Some(&loosen_attempt)),
6523            config_default,
6524            "looser per-request values must be clamped to the config ceiling"
6525        );
6526
6527        // Nor can it REMOVE a configured ceiling by omitting the field: an
6528        // absent override field keeps the config default, it does not mean
6529        // unlimited.
6530        let empty_override = RunBudgetConfig::default();
6531        assert_eq!(
6532            config_default.merged_with_override(Some(&empty_override)),
6533            config_default,
6534            "an all-absent override body keeps every configured ceiling"
6535        );
6536
6537        // An unlimited config default CAN be tightened by the request (the
6538        // request is the only ceiling then), and stays unlimited on fields the
6539        // request does not set.
6540        let unlimited_default = RunBudgetConfig::default();
6541        let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6542        assert_eq!(merged.max_total_tokens, Some(5_000));
6543        assert_eq!(merged.max_tool_calls, None);
6544        assert_eq!(merged.max_subagents, None);
6545        assert_eq!(merged.max_rounds, None);
6546
6547        // The round cap tightens the same way: a request can cap rounds below
6548        // an unlimited config default but never raise a configured ceiling.
6549        let tighten_rounds = RunBudgetConfig {
6550            max_total_tokens: None,
6551            max_tool_calls: None,
6552            max_subagents: None,
6553            max_rounds: Some(20),
6554        };
6555        let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6556        assert_eq!(merged.max_rounds, Some(20));
6557        let merged = config_default.merged_with_override(Some(&tighten_rounds));
6558        assert_eq!(
6559            merged.max_rounds,
6560            Some(20),
6561            "a stricter per-request round cap wins over the config default"
6562        );
6563    }
6564
6565    #[test]
6566    fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6567        assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6568        assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6569        assert_eq!(RunBudgetConfig::default().max_subagents, None);
6570        assert_eq!(RunBudgetConfig::default().max_rounds, None);
6571
6572        let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6573        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6574        assert_eq!(cfg.max_total_tokens, Some(250_000));
6575        assert_eq!(
6576            cfg.max_tool_calls, None,
6577            "absent field defaults to unlimited"
6578        );
6579        assert_eq!(
6580            cfg.max_rounds, None,
6581            "absent round cap defaults to unlimited"
6582        );
6583
6584        assert_eq!(cfg.max_subagents, Some(3));
6585
6586        // The round cap round-trips like the other fields.
6587        let json = r#"{ "max_rounds": 250 }"#;
6588        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6589        assert_eq!(cfg.max_rounds, Some(250));
6590        let round_tripped: RunBudgetConfig =
6591            serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6592        assert_eq!(round_tripped.max_rounds, Some(250));
6593        assert_eq!(round_tripped.max_total_tokens, None);
6594
6595        // Absent fields are omitted on serialize (skip_serializing_if), so an
6596        // all-default config round-trips to `{}` rather than four explicit
6597        // nulls.
6598        let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6599        assert_eq!(empty, "{}");
6600    }
6601
6602    #[test]
6603    fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6604        let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6605        assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6606        assert!(!serde_json::to_string(&old)
6607            .unwrap()
6608            .contains("max_spawn_depth"));
6609        for depth in [0, 1, 8] {
6610            let config: SubagentsConfig =
6611                serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6612            assert_eq!(config.effective_max_spawn_depth(), depth);
6613            assert_eq!(
6614                serde_json::to_value(config).unwrap()["max_spawn_depth"],
6615                depth
6616            );
6617        }
6618        for invalid in [
6619            serde_json::json!(-1),
6620            serde_json::json!(1.5),
6621            serde_json::json!("4"),
6622        ] {
6623            assert!(serde_json::from_value::<SubagentsConfig>(
6624                serde_json::json!({"max_spawn_depth": invalid}),
6625            )
6626            .is_err());
6627        }
6628    }
6629
6630    #[test]
6631    fn subagents_config_without_remote_placements_deserializes_empty() {
6632        // An OLD config (predating P1.5) has no `remote_placements` key — it must
6633        // still deserialize, with an empty placement list (default = local path).
6634        let json = r#"{ "max_concurrent": 4 }"#;
6635        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6636        assert_eq!(cfg.max_concurrent, Some(4));
6637        assert!(cfg.remote_placements.is_empty());
6638        // And an empty placement list is omitted on re-serialize (skip_if empty).
6639        let back = serde_json::to_string(&cfg).unwrap();
6640        assert!(
6641            !back.contains("remote_placements"),
6642            "empty vec is skipped: {back}"
6643        );
6644    }
6645
6646    #[test]
6647    fn remote_actor_placement_round_trips() {
6648        let json = r#"{
6649            "remote_placements": [
6650                {
6651                    "role": "explorer",
6652                    "endpoint": "wss://gpu-host:8443",
6653                    "token_env": "WORKER_TOKEN",
6654                    "ca_cert_file": "/etc/bamboo/worker.pem"
6655                },
6656                { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6657            ]
6658        }"#;
6659        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6660        assert_eq!(cfg.remote_placements.len(), 2);
6661        let p0 = &cfg.remote_placements[0];
6662        assert_eq!(p0.role, "explorer");
6663        assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6664        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6665        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6666        // Optional fields default to None and are skipped on serialize.
6667        let p1 = &cfg.remote_placements[1];
6668        assert_eq!(p1.role, "writer");
6669        assert!(p1.token_env.is_none());
6670        assert!(p1.ca_cert_file.is_none());
6671
6672        let back = serde_json::to_string(&cfg).unwrap();
6673        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6674        assert_eq!(cfg, reparsed, "round-trip is stable");
6675        assert!(!back.contains("\"token_env\":null"));
6676        assert!(!back.contains("\"ca_cert_file\":null"));
6677    }
6678
6679    #[test]
6680    fn subagents_config_without_schedulable_placements_deserializes_empty() {
6681        // An OLD config (predating P2b) has no `schedulable_placements` key — it
6682        // must still deserialize, with an empty list (default = local path).
6683        let json = r#"{ "max_concurrent": 4 }"#;
6684        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6685        assert!(cfg.schedulable_placements.is_empty());
6686        // An empty list is omitted on re-serialize (skip_if empty).
6687        let back = serde_json::to_string(&cfg).unwrap();
6688        assert!(
6689            !back.contains("schedulable_placements"),
6690            "empty vec is skipped: {back}"
6691        );
6692    }
6693
6694    #[test]
6695    fn schedulable_placement_round_trips() {
6696        let json = r#"{
6697            "schedulable_placements": [
6698                {
6699                    "role": "explorer",
6700                    "pool": "gpu-pool",
6701                    "registry_url": "https://control-plane:9562",
6702                    "token_env": "WORKER_TOKEN",
6703                    "ca_cert_file": "/etc/bamboo/worker.pem"
6704                },
6705                { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6706            ]
6707        }"#;
6708        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6709        assert_eq!(cfg.schedulable_placements.len(), 2);
6710        let p0 = &cfg.schedulable_placements[0];
6711        assert_eq!(p0.role, "explorer");
6712        assert_eq!(p0.pool, "gpu-pool");
6713        assert_eq!(p0.registry_url, "https://control-plane:9562");
6714        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6715        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6716        // Optional fields default to None and are skipped on serialize.
6717        let p1 = &cfg.schedulable_placements[1];
6718        assert_eq!(p1.role, "writer");
6719        assert_eq!(p1.pool, "cpu-pool");
6720        assert!(p1.token_env.is_none());
6721        assert!(p1.ca_cert_file.is_none());
6722
6723        let back = serde_json::to_string(&cfg).unwrap();
6724        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6725        assert_eq!(cfg, reparsed, "round-trip is stable");
6726        assert!(!back.contains("\"token_env\":null"));
6727        assert!(!back.contains("\"ca_cert_file\":null"));
6728    }
6729
6730    #[test]
6731    fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6732        // An OLD config (predating #54's wiring) has no `mcp_role_allowlist`
6733        // key — it must still deserialize, with an empty list (default =
6734        // every role unrestricted, identical to pre-#54 behavior).
6735        let json = r#"{ "max_concurrent": 4 }"#;
6736        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6737        assert!(cfg.mcp_role_allowlist.is_empty());
6738        // An empty list is omitted on re-serialize (skip_if empty).
6739        let back = serde_json::to_string(&cfg).unwrap();
6740        assert!(
6741            !back.contains("mcp_role_allowlist"),
6742            "empty vec is skipped: {back}"
6743        );
6744    }
6745
6746    #[test]
6747    fn mcp_role_allowlist_entry_round_trips() {
6748        let json = r#"{
6749            "mcp_role_allowlist": [
6750                { "role": "researcher", "tools": ["fetch_url"] },
6751                { "role": "sandboxed", "tools": [] }
6752            ]
6753        }"#;
6754        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6755        assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6756        assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6757        assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6758        // An empty `tools` list is an explicit lockout, distinct from the role
6759        // being absent — it must round-trip as an empty (not omitted) list.
6760        assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6761        assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6762
6763        let back = serde_json::to_string(&cfg).unwrap();
6764        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6765        assert_eq!(cfg, reparsed, "round-trip is stable");
6766    }
6767
6768    #[test]
6769    fn server_config_without_tls_field_deserializes_back_compat() {
6770        // An old config.json `server` section with no `tls` key must still
6771        // deserialize, leaving `tls` as None (zero behavior change on upgrade).
6772        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6773            "port": 9562,
6774            "bind": "127.0.0.1"
6775        }))
6776        .expect("legacy server config without tls should deserialize");
6777
6778        assert_eq!(server.tls, None);
6779        assert_eq!(server.port, 9562);
6780        assert_eq!(server.bind, "127.0.0.1");
6781    }
6782
6783    #[test]
6784    fn server_config_omits_tls_when_none() {
6785        // `skip_serializing_if = "Option::is_none"` keeps the on-disk shape
6786        // identical to before for the common (no-TLS) case.
6787        let server = ServerConfig::default();
6788        let value = serde_json::to_value(&server).expect("server config should serialize");
6789        let obj = value
6790            .as_object()
6791            .expect("server config serializes to object");
6792        assert!(
6793            !obj.contains_key("tls"),
6794            "tls must be omitted when None, got: {value}"
6795        );
6796    }
6797
6798    #[test]
6799    fn server_config_with_tls_roundtrips() {
6800        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6801            "port": 9562,
6802            "bind": "0.0.0.0",
6803            "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6804        }))
6805        .expect("server config with tls should deserialize");
6806
6807        let tls = server.tls.clone().expect("tls should be Some");
6808        assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6809        assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6810
6811        // Round-trips: tls survives a serialize → deserialize cycle.
6812        let value = serde_json::to_value(&server).expect("serialize");
6813        assert!(value.as_object().unwrap().contains_key("tls"));
6814        let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6815        assert_eq!(back.tls, server.tls);
6816    }
6817
6818    #[test]
6819    fn access_control_without_devices_field_deserializes_back_compat() {
6820        // An old config.json `access_control` with no `devices` key must still
6821        // deserialize, leaving `devices` empty (root-password-only mode).
6822        let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6823            "password_enabled": true,
6824            "password_hash": "deadbeef",
6825            "password_salt": "01020304",
6826        }))
6827        .expect("legacy access_control without devices should deserialize");
6828
6829        assert!(access.devices.is_empty());
6830        assert!(access.password_enabled);
6831    }
6832
6833    #[test]
6834    fn access_control_omits_devices_when_empty() {
6835        // `skip_serializing_if = "Vec::is_empty"` keeps the on-disk shape
6836        // identical for instances that never paired a device.
6837        let access = AccessControlConfig {
6838            password_enabled: true,
6839            repair_required: false,
6840            password_hash: Some("deadbeef".to_string()),
6841            password_salt: Some("01020304".to_string()),
6842            password_credential_ref: None,
6843            password_configured: false,
6844            updated_at: None,
6845            devices: Vec::new(),
6846        };
6847        let value = serde_json::to_value(&access).expect("serialize");
6848        let obj = value.as_object().expect("object");
6849        assert!(
6850            !obj.contains_key("devices"),
6851            "devices must be omitted when empty, got: {value}"
6852        );
6853    }
6854
6855    #[test]
6856    fn access_control_with_devices_roundtrips() {
6857        let device = DeviceCredential {
6858            device_id: "bamboo_0123456789ab".to_string(),
6859            label: "iPhone 15".to_string(),
6860            token_hash: "abcd".to_string(),
6861            token_salt: "ef01".to_string(),
6862            token_credential_ref: None,
6863            token_configured: false,
6864            created_at: "2026-06-23T00:00:00Z".to_string(),
6865            last_used_at: None,
6866            revoked: false,
6867        };
6868        let access = AccessControlConfig {
6869            password_enabled: true,
6870            repair_required: false,
6871            password_hash: Some("deadbeef".to_string()),
6872            password_salt: Some("01020304".to_string()),
6873            password_credential_ref: None,
6874            password_configured: false,
6875            updated_at: None,
6876            devices: vec![device.clone()],
6877        };
6878        let value = serde_json::to_value(&access).expect("serialize");
6879        assert!(value.as_object().unwrap().contains_key("devices"));
6880        assert!(value["devices"][0].get("token_hash").is_none());
6881        assert!(value["devices"][0].get("token_salt").is_none());
6882        let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6883        assert_eq!(back.devices[0].device_id, device.device_id);
6884        assert!(back.devices[0].token_hash.is_empty());
6885        assert!(back.devices[0].token_salt.is_empty());
6886    }
6887
6888    #[test]
6889    fn reasoning_effort_for_key_resolves_instance_id() {
6890        // Multi-instance mode: the routing key is an instance id and the effort
6891        // lives under provider_instances[<id>] — previously this fell through to
6892        // None because the resolver only matched literal provider types.
6893        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6894            "provider_type": "copilot",
6895            "reasoning_effort": "high",
6896        }))
6897        .expect("instance config should deserialize");
6898
6899        let mut config = Config::create_default();
6900        config
6901            .provider_instances
6902            .insert("copilot-work".to_string(), instance);
6903
6904        assert_eq!(
6905            config.reasoning_effort_for_key("copilot-work"),
6906            Some(ReasoningEffort::High),
6907        );
6908    }
6909
6910    #[test]
6911    fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6912        // Legacy mode: the `bodhi` provider previously had no match arm.
6913        let mut config = Config::create_default();
6914        config.providers.bodhi = Some(
6915            serde_json::from_value(serde_json::json!({
6916                "reasoning_effort": "xhigh",
6917            }))
6918            .expect("bodhi config should deserialize"),
6919        );
6920
6921        assert_eq!(
6922            config.reasoning_effort_for_key("bodhi"),
6923            Some(ReasoningEffort::Xhigh),
6924        );
6925    }
6926
6927    #[test]
6928    fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6929        let mut config = Config::create_default();
6930        config.providers.openai = Some(
6931            serde_json::from_value(serde_json::json!({
6932                "api_key": "sk-test",
6933                "reasoning_effort": "low",
6934            }))
6935            .expect("openai config should deserialize"),
6936        );
6937
6938        assert_eq!(
6939            config.reasoning_effort_for_key("openai"),
6940            Some(ReasoningEffort::Low),
6941        );
6942    }
6943
6944    #[test]
6945    fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6946        let config = Config::create_default();
6947        assert_eq!(config.reasoning_effort_for_key("nope"), None);
6948        assert_eq!(config.reasoning_effort_for_key("   "), None);
6949    }
6950
6951    struct TempHome {
6952        path: PathBuf,
6953    }
6954
6955    impl TempHome {
6956        fn new() -> Self {
6957            // `pid + nanos` alone is NOT collision-free (issue #486): every
6958            // test in this binary shares the pid, and two tests started
6959            // concurrently by the multi-threaded harness can observe the
6960            // same `SystemTime` nanos tick. Two `TempHome`s colliding on one
6961            // path means they share a directory — and the first test's
6962            // `Drop` (`remove_dir_all`) then yanks the directory out from
6963            // under the other test's in-flight `save_to_dir`, whose
6964            // tmp-file+rename dance fails with ENOENT ("Failed to write
6965            // config file ... os error 2" — `save_rotates_backup_generations`'s
6966            // exact one-off CI failure mode). A per-process atomic counter
6967            // in the name makes each instance unique unconditionally.
6968            static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6969                std::sync::atomic::AtomicU64::new(0);
6970            let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6971            let nanos = SystemTime::now()
6972                .duration_since(UNIX_EPOCH)
6973                .expect("clock should be after unix epoch")
6974                .as_nanos();
6975            let path = std::env::temp_dir().join(format!(
6976                "chat-core-config-test-{}-{}-{}",
6977                std::process::id(),
6978                nanos,
6979                unique
6980            ));
6981            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6982            Self { path }
6983        }
6984
6985        fn set_config_json(&self, content: &str) {
6986            // Treat `path` as the Bamboo data dir and write `config.json` into it.
6987            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
6988            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
6989            std::fs::write(self.path.join("config.json"), content)
6990                .expect("failed to write config.json");
6991        }
6992    }
6993
6994    impl Drop for TempHome {
6995        fn drop(&mut self) {
6996            let _ = std::fs::remove_dir_all(&self.path);
6997        }
6998    }
6999
7000    // Delegate to the single crate-wide test lock so env-mutating tests across
7001    // `config`, `encryption`, and `paths` serialize against one another (they
7002    // all mutate the same process-global env / static caches).
7003    fn env_lock() -> &'static Mutex<()> {
7004        crate::test_support::env_cache_lock()
7005    }
7006
7007    /// Acquire the environment lock, recovering from poison if a previous test failed
7008    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
7009        env_lock().lock().unwrap_or_else(|poisoned| {
7010            // Lock was poisoned by a previous test failure - recover it
7011            poisoned.into_inner()
7012        })
7013    }
7014
7015    #[test]
7016    fn parse_bool_env_true_values() {
7017        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
7018            assert!(parse_bool_env(value), "value {value:?} should be true");
7019        }
7020    }
7021
7022    #[test]
7023    fn parse_bool_env_false_values() {
7024        for value in ["0", "false", "no", "off", "", "  "] {
7025            assert!(!parse_bool_env(value), "value {value:?} should be false");
7026        }
7027    }
7028
7029    #[test]
7030    fn config_new_ignores_http_proxy_env_vars() {
7031        let _lock = env_lock_acquire();
7032        let temp_home = TempHome::new();
7033        temp_home.set_config_json(
7034            r#"{
7035  "http_proxy": "",
7036  "https_proxy": ""
7037}"#,
7038        );
7039
7040        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
7041        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
7042
7043        let config = Config::from_data_dir(Some(temp_home.path.clone()));
7044
7045        assert!(
7046            config.http_proxy.is_empty(),
7047            "config should ignore HTTP_PROXY env var"
7048        );
7049        assert!(
7050            config.https_proxy.is_empty(),
7051            "config should ignore HTTPS_PROXY env var"
7052        );
7053    }
7054
7055    #[test]
7056    fn config_new_loads_config_when_proxy_fields_omitted() {
7057        let _lock = env_lock_acquire();
7058        let temp_home = TempHome::new();
7059        temp_home.set_config_json(
7060            r#"{
7061  "provider": "openai",
7062  "providers": {
7063    "openai": {
7064      "api_key": "sk-test",
7065      "model": "gpt-4o"
7066    }
7067  }
7068}"#,
7069        );
7070
7071        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
7072        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
7073
7074        let config = Config::from_data_dir(Some(temp_home.path.clone()));
7075
7076        assert_eq!(
7077            config
7078                .providers
7079                .openai
7080                .as_ref()
7081                .and_then(|c| c.model.as_deref()),
7082            Some("gpt-4o"),
7083            "config should load provider model from config file even when proxy fields are omitted"
7084        );
7085        assert!(config.http_proxy.is_empty());
7086        assert!(config.https_proxy.is_empty());
7087    }
7088
7089    #[test]
7090    fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
7091        let _lock = crate::test_support::env_cache_lock_acquire();
7092        let mut config = Config::default();
7093        config.env_vars.extend([
7094            EnvVarEntry {
7095                name: "SECRET_TOKEN".to_string(),
7096                value: "top-secret".to_string(),
7097                secret: true,
7098                value_encrypted: None,
7099                credential_ref: None,
7100                configured: true,
7101                description: Some("Service token".to_string()),
7102            },
7103            EnvVarEntry {
7104                name: "API_BASE".to_string(),
7105                value: "https://internal.example".to_string(),
7106                secret: false,
7107                value_encrypted: None,
7108                credential_ref: None,
7109                configured: true,
7110                description: Some("Internal API base".to_string()),
7111            },
7112        ]);
7113
7114        config.publish_env_vars();
7115
7116        let injected = Config::current_env_vars();
7117        assert_eq!(
7118            injected.get("SECRET_TOKEN").map(String::as_str),
7119            Some("top-secret")
7120        );
7121        assert_eq!(
7122            injected.get("API_BASE").map(String::as_str),
7123            Some("https://internal.example")
7124        );
7125
7126        let prompt_safe = Config::current_prompt_safe_env_vars();
7127        assert_eq!(prompt_safe.len(), 2);
7128        assert!(prompt_safe.iter().any(|entry| {
7129            entry.name == "SECRET_TOKEN"
7130                && entry.secret
7131                && entry.description.as_deref() == Some("Service token")
7132        }));
7133        assert!(prompt_safe.iter().any(|entry| {
7134            entry.name == "API_BASE"
7135                && !entry.secret
7136                && entry.description.as_deref() == Some("Internal API base")
7137        }));
7138        assert!(!prompt_safe
7139            .iter()
7140            .any(|entry| entry.name.contains("top-secret")));
7141        assert!(!prompt_safe.iter().any(|entry| {
7142            entry
7143                .description
7144                .as_deref()
7145                .is_some_and(|value| value.contains("https://internal.example"))
7146        }));
7147    }
7148
7149    #[test]
7150    fn from_data_dir_without_publish_does_not_clobber_global_cache() {
7151        let _lock = crate::test_support::env_cache_lock_acquire();
7152
7153        // Seed the global cache with a marker "owned" by the live config.
7154        let mut live = Config::default();
7155        live.env_vars.extend([EnvVarEntry {
7156            name: "BAMBOO_CACHE_OWNER_40".to_string(),
7157            value: "live".to_string(),
7158            secret: false,
7159            value_encrypted: None,
7160            credential_ref: None,
7161            configured: true,
7162            description: None,
7163        }]);
7164        live.publish_env_vars();
7165        assert_eq!(
7166            Config::current_env_vars()
7167                .get("BAMBOO_CACHE_OWNER_40")
7168                .map(String::as_str),
7169            Some("live")
7170        );
7171
7172        // A config.json on disk sets the SAME var to a different (stale) value.
7173        let temp = TempHome::new();
7174        temp.set_config_json(
7175            &serde_json::json!({
7176                "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
7177            })
7178            .to_string(),
7179        );
7180
7181        // Non-publishing load reads the disk value into the returned Config but
7182        // must NOT touch the global cache.
7183        let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7184        assert_eq!(
7185            loaded
7186                .env_vars
7187                .iter()
7188                .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
7189                .map(|e| e.value.as_str()),
7190            Some("stale-disk"),
7191            "the returned Config holds the disk value"
7192        );
7193        assert_eq!(
7194            Config::current_env_vars()
7195                .get("BAMBOO_CACHE_OWNER_40")
7196                .map(String::as_str),
7197            Some("live"),
7198            "but the global cache is UNTOUCHED — no clobber (#40)"
7199        );
7200
7201        // Contrast: the publishing variant DOES clobber the cache.
7202        let _ = Config::from_data_dir(Some(temp.path.clone()));
7203        assert_eq!(
7204            Config::current_env_vars()
7205                .get("BAMBOO_CACHE_OWNER_40")
7206                .map(String::as_str),
7207            Some("stale-disk"),
7208            "the publishing loader clobbers the cache (contrast)"
7209        );
7210    }
7211
7212    fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
7213        std::fs::read_dir(dir)
7214            .unwrap()
7215            .filter_map(|e| e.ok())
7216            .any(|e| {
7217                e.file_name()
7218                    .to_string_lossy()
7219                    .contains("config.json.corrupted.")
7220            })
7221    }
7222
7223    #[test]
7224    fn corrupt_config_recovered_from_backup_and_quarantined() {
7225        let temp = TempHome::new();
7226        // Last-known-good backup with a distinctive value.
7227        std::fs::write(
7228            temp.path.join("config.json.bak"),
7229            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7230        )
7231        .unwrap();
7232        // Corrupt primary config.json.
7233        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7234
7235        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7236        assert_eq!(
7237            config.http_proxy, "http://from-backup",
7238            "recovered from config.json.bak instead of losing all config"
7239        );
7240        assert!(
7241            dir_has_quarantine_file(&temp.path),
7242            "corrupt config.json was quarantined (preserved), not discarded"
7243        );
7244    }
7245
7246    #[test]
7247    fn corrupt_config_without_backup_quarantines_then_defaults() {
7248        let temp = TempHome::new();
7249        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7250
7251        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7252        assert!(
7253            config.http_proxy.is_empty(),
7254            "no backup -> falls back to defaults"
7255        );
7256        assert!(
7257            dir_has_quarantine_file(&temp.path),
7258            "corrupt config.json is quarantined even when there's no backup"
7259        );
7260    }
7261
7262    #[test]
7263    fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
7264        let temp = TempHome::new();
7265        // A valid JSON OBJECT, but `env_vars` is the wrong type (string, not array)
7266        // so STRICT parse fails. There is NO config.json.bak, so recovery must come
7267        // from field-level salvage: `http_proxy` is valid and must survive; the bad
7268        // `env_vars` resets to its default.
7269        temp.set_config_json(
7270            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7271        );
7272
7273        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7274        assert_eq!(
7275            config.http_proxy, "http://salvaged",
7276            "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
7277        );
7278        assert!(
7279            config.env_vars.is_empty(),
7280            "the corrupt field reset to its default instead of failing the whole load"
7281        );
7282        assert!(
7283            dir_has_quarantine_file(&temp.path),
7284            "the corrupt config.json was still quarantined for inspection"
7285        );
7286    }
7287
7288    #[test]
7289    fn salvage_preferred_over_backup_for_most_recent_intent() {
7290        let temp = TempHome::new();
7291        // An OLDER last-known-good backup...
7292        std::fs::write(
7293            temp.path.join("config.json.bak"),
7294            serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
7295        )
7296        .unwrap();
7297        // ...and a NEWER config that is corrupt but field-salvageable.
7298        temp.set_config_json(
7299            r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
7300        );
7301
7302        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7303        assert_eq!(
7304            config.http_proxy, "http://new-salvaged",
7305            "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7306        );
7307    }
7308
7309    #[test]
7310    fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7311        let temp = TempHome::new();
7312        // Backup carries TWO good values.
7313        std::fs::write(
7314            temp.path.join("config.json.bak"),
7315            serde_json::json!({
7316                "http_proxy": "http://old-from-backup",
7317                "https_proxy": "https://kept-from-backup",
7318            })
7319            .to_string(),
7320        )
7321        .unwrap();
7322        // The corrupt file updates http_proxy (newer), leaves https_proxy untouched,
7323        // and has one wrong-type field.
7324        temp.set_config_json(
7325            r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7326        );
7327
7328        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7329        // Best of both: the corrupt file's newer valid edit wins where it set one...
7330        assert_eq!(
7331            config.http_proxy, "http://newer-edit",
7332            "the corrupt file's newer valid edit is applied"
7333        );
7334        // ...and the backup's value survives for fields the corrupt file didn't fix.
7335        assert_eq!(
7336            config.https_proxy, "https://kept-from-backup",
7337            "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7338        );
7339    }
7340
7341    #[test]
7342    fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7343        let temp = TempHome::new();
7344        std::fs::write(
7345            temp.path.join("config.json.bak"),
7346            serde_json::json!({
7347                "providers": {
7348                    "anthropic": { "model": "claude-backup" }
7349                },
7350                "memory": {
7351                    "auto_dream_enabled": true
7352                },
7353                "subagents": {
7354                    "claude_code_model": "claude-code-backup"
7355                }
7356            })
7357            .to_string(),
7358        )
7359        .unwrap();
7360        assert!(!temp.path.join("providers.json").exists());
7361        assert!(!temp.path.join("memory.json").exists());
7362        assert!(!temp.path.join("subagents.json").exists());
7363
7364        let (salvaged, recovered_fields) = Config::salvage_partial(
7365            r#"{"providers":"schema-invalid-provider-section"}"#,
7366            &temp.path,
7367        )
7368        .expect("object-shaped corrupt config should be salvageable");
7369
7370        assert!(
7371            recovered_fields.is_empty(),
7372            "the schema-invalid provider field must not replace the backup baseline"
7373        );
7374        assert_eq!(
7375            salvaged
7376                .providers()
7377                .anthropic
7378                .as_ref()
7379                .and_then(|provider| provider.model.as_deref()),
7380            Some("claude-backup")
7381        );
7382        assert!(
7383            salvaged
7384                .memory()
7385                .as_ref()
7386                .expect("backup memory config survives")
7387                .auto_dream_enabled
7388        );
7389        assert_eq!(
7390            salvaged.subagents().claude_code_model.as_deref(),
7391            Some("claude-code-backup")
7392        );
7393    }
7394
7395    #[test]
7396    fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7397        let temp = TempHome::new();
7398        // Not even a JSON object -> nothing field-wise to salvage -> must fall
7399        // through to the .bak (the pre-#135 behavior is preserved).
7400        std::fs::write(
7401            temp.path.join("config.json.bak"),
7402            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7403        )
7404        .unwrap();
7405        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7406
7407        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7408        assert_eq!(
7409            config.http_proxy, "http://from-backup",
7410            "garbage (non-object) config skips salvage and recovers from .bak"
7411        );
7412    }
7413
7414    #[test]
7415    fn quarantine_files_are_capped_to_newest_n() {
7416        let temp = TempHome::new();
7417        let config_path = temp.path.join("config.json");
7418        std::fs::write(&config_path, "{}").unwrap();
7419
7420        // Drop more quarantines than the cap; each call sleeps so nanos (the name)
7421        // and mtime (the prune sort key) are distinct.
7422        for _ in 0..(QUARANTINE_KEEP + 3) {
7423            quarantine_corrupt_config(&config_path);
7424            std::thread::sleep(std::time::Duration::from_millis(3));
7425        }
7426
7427        let count = std::fs::read_dir(&temp.path)
7428            .unwrap()
7429            .filter_map(|e| e.ok())
7430            .filter(|e| {
7431                e.file_name()
7432                    .to_string_lossy()
7433                    .starts_with("config.json.corrupted.")
7434            })
7435            .count();
7436        assert_eq!(
7437            count, QUARANTINE_KEEP,
7438            "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7439        );
7440    }
7441
7442    #[test]
7443    fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7444        let temp = TempHome::new();
7445        // Primary AND the freshest .bak are corrupt; an older generation is good.
7446        std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7447        std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7448        std::fs::write(
7449            temp.path.join("config.json.bak.1"),
7450            serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7451        )
7452        .unwrap();
7453
7454        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7455        assert_eq!(
7456            config.http_proxy, "http://from-gen-1",
7457            "recovered from .bak.1 when both config.json and .bak are corrupt"
7458        );
7459    }
7460
7461    #[test]
7462    fn save_rotates_backup_generations() {
7463        let temp = TempHome::new();
7464        let path = temp.path.join("config.json");
7465        // v1 is the existing on-disk config.
7466        std::fs::write(
7467            &path,
7468            serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7469        )
7470        .unwrap();
7471
7472        let mut cfg = Config::create_default();
7473        // Save 1: backs up the existing v1 -> .bak, writes v2.
7474        cfg.http_proxy = "http://proxy-v2".to_string();
7475        cfg.save_to_dir(temp.path.clone()).unwrap();
7476        // Save 2: existing (v2) is parseable -> rotate .bak(v1) -> .bak.1, .bak = v2.
7477        cfg.http_proxy = "http://proxy-v3".to_string();
7478        cfg.save_to_dir(temp.path.clone()).unwrap();
7479
7480        let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7481        let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7482        assert!(
7483            bak.contains("proxy-v2"),
7484            ".bak holds the previous generation (v2)"
7485        );
7486        assert!(
7487            bak1.contains("proxy-v1"),
7488            ".bak.1 holds the older rotated generation (v1)"
7489        );
7490    }
7491
7492    #[test]
7493    fn save_backs_up_existing_config() {
7494        let temp = TempHome::new();
7495        // Existing (old) config on disk.
7496        std::fs::write(
7497            temp.path.join("config.json"),
7498            serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7499        )
7500        .unwrap();
7501
7502        let mut config = Config::create_default();
7503        config.http_proxy = "http://new".to_string();
7504        config
7505            .save_to_dir(temp.path.clone())
7506            .expect("save succeeds");
7507
7508        let backup =
7509            std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7510        assert!(
7511            backup.contains("http://old"),
7512            "config.json.bak holds the PREVIOUS config (last-known-good)"
7513        );
7514        let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7515        assert!(
7516            current.contains("http://new"),
7517            "config.json holds the new config"
7518        );
7519    }
7520
7521    #[test]
7522    fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7523        let temp = TempHome::new();
7524        // A good last-known-good backup...
7525        std::fs::write(
7526            temp.path.join("config.json.bak"),
7527            serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7528        )
7529        .unwrap();
7530        // ...but the on-disk config.json is corrupt (as it would be right after an
7531        // in-memory recovery, before any clean save).
7532        std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7533
7534        let mut config = Config::create_default();
7535        config.http_proxy = "http://new".to_string();
7536        config
7537            .save_to_dir(temp.path.clone())
7538            .expect("save succeeds");
7539
7540        // The good .bak must NOT have been clobbered by the corrupt config.json.
7541        let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7542        assert!(
7543            backup.contains("http://good-bak"),
7544            "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7545        );
7546    }
7547
7548    // ── config-corruption recovery confirmation gate (#153) ───────────────
7549
7550    #[test]
7551    fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7552        let temp = TempHome::new();
7553        std::fs::write(
7554            temp.path.join("config.json.bak"),
7555            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7556        )
7557        .unwrap();
7558        let corrupt_bytes = "{ not valid json ";
7559        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7560
7561        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7562        let status = config
7563            .recovery_status()
7564            .expect("a corrupt load must set a pending recovery status");
7565        assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7566        assert_eq!(
7567            status.source,
7568            ConfigRecoverySource::Backup { generation: 0 },
7569            "recovered from generation-0 (.bak)"
7570        );
7571        let quarantine_path = status
7572            .quarantine_path
7573            .as_ref()
7574            .expect("quarantine copy should have succeeded");
7575        assert_eq!(
7576            std::fs::read_to_string(quarantine_path).unwrap(),
7577            corrupt_bytes,
7578            "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7579        );
7580        assert_eq!(
7581            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7582            corrupt_bytes,
7583            "the original config.json itself is untouched by the load (only copied, not moved)"
7584        );
7585    }
7586
7587    #[test]
7588    fn recovery_status_set_from_salvage_lists_recovered_fields() {
7589        let temp = TempHome::new();
7590        temp.set_config_json(
7591            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7592        );
7593
7594        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7595        let status = config.recovery_status().expect("pending recovery");
7596        assert!(!status.confirmed);
7597        match &status.source {
7598            ConfigRecoverySource::Salvaged { fields } => {
7599                assert!(
7600                    fields.iter().any(|f| f == "http_proxy"),
7601                    "salvaged fields should list the recovered key: {fields:?}"
7602                );
7603            }
7604            other => panic!("expected Salvaged source, got {other:?}"),
7605        }
7606    }
7607
7608    #[test]
7609    fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7610        let temp = TempHome::new();
7611        // Not a JSON object at all -> salvage impossible; no .bak -> defaults.
7612        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7613
7614        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7615        let status = config.recovery_status().expect("pending recovery");
7616        assert!(!status.confirmed);
7617        assert_eq!(status.source, ConfigRecoverySource::Defaults);
7618    }
7619
7620    #[test]
7621    fn clean_load_never_sets_recovery_status() {
7622        let temp = TempHome::new();
7623        temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7624
7625        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7626        assert!(
7627            config.recovery_status().is_none(),
7628            "a config.json that parses cleanly must never carry a pending recovery status"
7629        );
7630    }
7631
7632    #[test]
7633    fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7634        let temp = TempHome::new();
7635        let corrupt_bytes = "}}} broken";
7636        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7637
7638        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7639        assert!(config.recovery_status().is_some());
7640
7641        let err = config
7642            .save_to_dir(temp.path.clone())
7643            .expect_err("save must refuse while recovery is unconfirmed");
7644        assert!(
7645            err.to_string().contains("recovered from corruption")
7646                || err.to_string().contains("confirm"),
7647            "error should explain the refused overwrite: {err}"
7648        );
7649
7650        // The corrupt original on disk must be BYTE FOR BYTE unchanged — the
7651        // refused save must not have touched it at all.
7652        assert_eq!(
7653            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7654            corrupt_bytes,
7655            "a refused save must leave the corrupt original untouched"
7656        );
7657    }
7658
7659    #[test]
7660    fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7661        let temp = TempHome::new();
7662        // Simulates a crash mid-write: valid JSON prefix, abruptly cut off.
7663        let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7664        std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7665
7666        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7667        let status = config.recovery_status().expect("pending recovery");
7668        let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7669        assert_eq!(
7670            std::fs::read_to_string(quarantine_path).unwrap(),
7671            truncated,
7672            "truncated original preserved byte for byte in quarantine"
7673        );
7674
7675        let err = config.save_to_dir(temp.path.clone());
7676        assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7677        assert_eq!(
7678            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7679            truncated,
7680            "the half-written original stays exactly as it was after a refused save"
7681        );
7682    }
7683
7684    #[test]
7685    fn confirm_recovery_allows_the_next_save() {
7686        let temp = TempHome::new();
7687        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7688
7689        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7690        assert!(config.recovery_status().is_some());
7691
7692        config.confirm_recovery();
7693        assert!(
7694            config.recovery_status().is_some_and(|s| s.confirmed),
7695            "confirm_recovery flips the flag but keeps the status around"
7696        );
7697
7698        config
7699            .save_to_dir(temp.path.clone())
7700            .expect("save must succeed once the recovery is confirmed");
7701    }
7702
7703    #[test]
7704    fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7705        let temp = TempHome::new();
7706        std::fs::write(
7707            temp.path.join("config.json"),
7708            r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7709        )
7710        .unwrap();
7711
7712        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7713        assert!(config.recovery_status().is_some());
7714        let quarantine_path = config
7715            .recovery_status()
7716            .unwrap()
7717            .quarantine_path
7718            .clone()
7719            .unwrap();
7720
7721        config
7722            .confirm_recovery_and_save_to_dir(temp.path.clone())
7723            .expect("confirm+save should succeed");
7724
7725        assert!(
7726            config.recovery_status().is_none(),
7727            "the pending flag is cleared once the recovery is confirmed and persisted"
7728        );
7729        let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7730        assert!(
7731            on_disk.contains("http://recovered"),
7732            "config.json now holds the recovered (salvaged) state"
7733        );
7734        // The quarantine copy of the original corrupt file must still exist,
7735        // untouched, even after the recovery is confirmed and persisted.
7736        assert!(
7737            quarantine_path.exists(),
7738            "the quarantined original survives confirmation — it's never deleted"
7739        );
7740    }
7741
7742    #[test]
7743    fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7744        let temp = TempHome::new();
7745        let mut config = Config::create_default();
7746        let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7747        assert!(
7748            err.is_err(),
7749            "confirming a recovery that was never pending must error, not silently succeed"
7750        );
7751    }
7752
7753    // ── connect.json split (#455) ────────────────────────────────────────
7754
7755    fn connect_platform_with_encrypted(
7756        platform_type: &str,
7757        token_encrypted: &str,
7758    ) -> ConnectPlatformConfig {
7759        ConnectPlatformConfig {
7760            id: None,
7761            project_id: None,
7762            platform_type: platform_type.to_string(),
7763            token: None,
7764            token_encrypted: Some(token_encrypted.to_string()),
7765            token_credential_ref: None,
7766            token_configured: false,
7767            app_id: None,
7768            app_secret: None,
7769            app_secret_encrypted: None,
7770            app_secret_credential_ref: None,
7771            app_secret_configured: false,
7772            domain: None,
7773            allow_from: vec!["user-1".to_string()],
7774            admin_from: Vec::new(),
7775        }
7776    }
7777
7778    fn connect_json_path(temp: &TempHome) -> PathBuf {
7779        temp.path.join("connect.json")
7780    }
7781
7782    #[test]
7783    fn save_splits_connect_into_sibling_connect_json() {
7784        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7785        let temp = TempHome::new();
7786
7787        let mut config = Config::create_default();
7788        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7789        config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7790
7791        config
7792            .save_to_dir(temp.path.clone())
7793            .expect("save succeeds");
7794
7795        let config_json: serde_json::Value =
7796            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7797                .unwrap();
7798        assert!(
7799            config_json.get("connect").is_none(),
7800            "config.json must not carry the `connect` key after a save"
7801        );
7802
7803        let connect_json: serde_json::Value =
7804            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7805                .unwrap();
7806        assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7807        assert!(
7808            connect_json["platforms"][0]["token_encrypted"]
7809                .as_str()
7810                .is_some_and(|v| !v.is_empty()),
7811            "the token is persisted in its encrypted form in connect.json"
7812        );
7813        assert!(
7814            connect_json["platforms"][0].get("token").is_none(),
7815            "the plaintext token is never persisted (skip_serializing)"
7816        );
7817    }
7818
7819    // ── stable connect.platforms id (#496) ───────────────────────────────
7820
7821    #[test]
7822    fn save_assigns_a_missing_connect_platform_id() {
7823        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7824        let temp = TempHome::new();
7825
7826        let mut config = Config::create_default();
7827        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7828        assert!(
7829            config.connect.platforms[0].id.is_none(),
7830            "precondition: the entry starts without an id"
7831        );
7832
7833        config
7834            .save_to_dir(temp.path.clone())
7835            .expect("save succeeds");
7836
7837        let connect_json: serde_json::Value =
7838            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7839                .unwrap();
7840        let persisted_id = connect_json["platforms"][0]["id"]
7841            .as_str()
7842            .expect("save_to_dir must backfill a missing id onto the persisted entry");
7843        assert!(!persisted_id.is_empty());
7844
7845        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7846        assert_eq!(
7847            reloaded.connect.platforms[0].id.as_deref(),
7848            Some(persisted_id),
7849            "the assigned id round-trips through a reload"
7850        );
7851    }
7852
7853    #[test]
7854    fn save_never_reassigns_an_existing_connect_platform_id() {
7855        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7856        let temp = TempHome::new();
7857
7858        let mut config = Config::create_default();
7859        let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7860        platform.id = Some("stable-id-123".to_string());
7861        config.connect.platforms = vec![platform];
7862
7863        config
7864            .save_to_dir(temp.path.clone())
7865            .expect("first save succeeds");
7866        // Save again (e.g. an unrelated settings change) — the id must not change.
7867        config
7868            .save_to_dir(temp.path.clone())
7869            .expect("second save succeeds");
7870
7871        let connect_json: serde_json::Value =
7872            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7873                .unwrap();
7874        assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7875    }
7876
7877    #[test]
7878    fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7879        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7880        let temp = TempHome::new();
7881
7882        let mut config = Config::create_default();
7883        config.connect.platforms = vec![
7884            connect_platform_with_encrypted("telegram", "cipher-a"),
7885            connect_platform_with_encrypted("telegram", "cipher-b"),
7886        ];
7887
7888        config
7889            .save_to_dir(temp.path.clone())
7890            .expect("save succeeds");
7891
7892        let connect_json: serde_json::Value =
7893            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7894                .unwrap();
7895        let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7896        let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7897        assert_ne!(
7898            id_a, id_b,
7899            "two entries sharing platform_type must still get distinct ids"
7900        );
7901    }
7902
7903    #[test]
7904    fn load_never_assigns_or_persists_an_id_by_itself() {
7905        let temp = TempHome::new();
7906        std::fs::write(
7907            connect_json_path(&temp),
7908            serde_json::json!({
7909                "platforms": [
7910                    { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7911                ]
7912            })
7913            .to_string(),
7914        )
7915        .unwrap();
7916        let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7917
7918        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7919
7920        assert!(
7921            config.connect.platforms[0].id.is_none(),
7922            "load alone must not backfill an id in memory"
7923        );
7924        let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7925        assert_eq!(
7926            connect_json_before, connect_json_after,
7927            "load must never rewrite connect.json on disk just to backfill an id (#493)"
7928        );
7929    }
7930
7931    #[test]
7932    fn load_merges_connect_json_into_config() {
7933        let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7934        let temp = TempHome::new();
7935        let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7936        std::fs::write(
7937            connect_json_path(&temp),
7938            serde_json::json!({
7939                "platforms": [
7940                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7941                ]
7942            })
7943            .to_string(),
7944        )
7945        .unwrap();
7946
7947        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7948        assert_eq!(config.connect.platforms.len(), 1);
7949        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7950        assert_eq!(
7951            config.connect.platforms[0].token.as_deref(),
7952            Some("connect-secret")
7953        );
7954        assert!(config.connect.platforms[0].token_encrypted.is_none());
7955    }
7956
7957    #[test]
7958    fn load_without_connect_json_yields_empty_inert_connect_config() {
7959        let temp = TempHome::new();
7960        temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7961
7962        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7963        assert!(
7964            config.connect.platforms.is_empty(),
7965            "no connect.json and no legacy key -> empty/inert connect config"
7966        );
7967        assert!(
7968            !connect_json_path(&temp).exists(),
7969            "load must not create connect.json when there is nothing to migrate"
7970        );
7971    }
7972
7973    #[test]
7974    fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7975        let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7976        let temp = TempHome::new();
7977        let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7978        let legacy_cipher_for_assert = legacy_cipher.clone();
7979        // Legacy state (#453): connect lives inline in config.json, no connect.json yet.
7980        temp.set_config_json(
7981            &serde_json::json!({
7982                "http_proxy": "http://keep-me",
7983                "connect": {
7984                    "platforms": [
7985                        { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7986                    ]
7987                }
7988            })
7989            .to_string(),
7990        );
7991
7992        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7993
7994        // In-memory: legacy value adopted.
7995        assert_eq!(config.connect.platforms.len(), 1);
7996        assert_eq!(
7997            config.connect.platforms[0].token.as_deref(),
7998            Some("legacy-connect-secret")
7999        );
8000        // An unrelated field from the same load survives the migration rewrite.
8001        assert_eq!(config.http_proxy, "http://keep-me");
8002
8003        // On disk: connect.json was created...
8004        assert!(
8005            connect_json_path(&temp).exists(),
8006            "migration proactively creates connect.json"
8007        );
8008        let connect_json: serde_json::Value =
8009            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8010                .unwrap();
8011        assert_eq!(
8012            connect_json["platforms"][0]["token_encrypted"],
8013            legacy_cipher_for_assert
8014        );
8015
8016        // ...and config.json was rewritten without the `connect` key.
8017        let config_json: serde_json::Value =
8018            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8019                .unwrap();
8020        assert!(
8021            config_json.get("connect").is_none(),
8022            "config.json is rewritten without the legacy `connect` key"
8023        );
8024    }
8025
8026    /// #457: the legacy-key migration must be a NARROW write (strip `connect`
8027    /// from config.json + write connect.json) — not the full `save_to_dir`,
8028    /// which would re-encrypt every OTHER secret in config.json and rotate a
8029    /// `config.json.bak` generation as a load-time side effect. This matters
8030    /// most for a purely READ-ONLY command (e.g. `bamboo config get`) run on a
8031    /// machine that still has the legacy `connect` key: it must not silently
8032    /// rewrite/re-encrypt unrelated secrets or spin up a backup.
8033    #[test]
8034    fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
8035        let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
8036        let temp = TempHome::new();
8037
8038        let original_api_key_encrypted =
8039            crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
8040        temp.set_config_json(
8041            &serde_json::json!({
8042                "providers": {
8043                    "openai": {
8044                        "api_key_encrypted": original_api_key_encrypted,
8045                    }
8046                },
8047                "connect": {
8048                    "platforms": [
8049                        { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
8050                    ]
8051                }
8052            })
8053            .to_string(),
8054        );
8055
8056        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8057        assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
8058
8059        // No `config.json.bak` — the narrow write does not rotate backups the
8060        // way a full `save_to_dir` would.
8061        assert!(
8062            !temp.path.join("config.json.bak").exists(),
8063            "a read-only load migrating a legacy `connect` key must not rotate \
8064             config.json backups"
8065        );
8066
8067        // The unrelated provider secret's ciphertext is byte-for-byte
8068        // unchanged — proof it was never decrypted+re-encrypted (encryption
8069        // uses a random nonce per call, so any re-encryption would change the
8070        // bytes even for the same plaintext).
8071        let config_json: serde_json::Value =
8072            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8073                .unwrap();
8074        assert_eq!(
8075            config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
8076            "an unrelated secret's ciphertext must not be touched by the connect \
8077             migration's narrow write"
8078        );
8079        assert!(
8080            config_json.get("connect").is_none(),
8081            "config.json is still rewritten without the legacy `connect` key"
8082        );
8083    }
8084
8085    #[test]
8086    fn both_files_present_connect_json_wins() {
8087        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
8088        let temp = TempHome::new();
8089        let stale = crate::encryption::encrypt("stale-secret").unwrap();
8090        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8091        temp.set_config_json(
8092            &serde_json::json!({
8093                "connect": {
8094                    "platforms": [
8095                        { "type": "telegram", "token_encrypted": stale }
8096                    ]
8097                }
8098            })
8099            .to_string(),
8100        );
8101        std::fs::write(
8102            connect_json_path(&temp),
8103            serde_json::json!({
8104                "platforms": [
8105                    { "type": "telegram", "token_encrypted": authoritative }
8106                ]
8107            })
8108            .to_string(),
8109        )
8110        .unwrap();
8111
8112        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8113        assert_eq!(
8114            config.connect.platforms[0].token.as_deref(),
8115            Some("authoritative-secret"),
8116            "connect.json wins over a stale legacy config.json key"
8117        );
8118    }
8119
8120    /// #457: when both files are present, the superseded `connect` key in
8121    /// config.json must be stripped PROACTIVELY on load — not left to linger
8122    /// until the next natural save, which spreads token ciphertext across two
8123    /// files for longer than necessary.
8124    #[test]
8125    fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
8126        let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
8127        let temp = TempHome::new();
8128        let stale = crate::encryption::encrypt("stale-secret").unwrap();
8129        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8130        temp.set_config_json(
8131            &serde_json::json!({
8132                "http_proxy": "http://keep-me",
8133                "connect": {
8134                    "platforms": [
8135                        { "type": "telegram", "token_encrypted": stale }
8136                    ]
8137                }
8138            })
8139            .to_string(),
8140        );
8141        std::fs::write(
8142            connect_json_path(&temp),
8143            serde_json::json!({
8144                "platforms": [
8145                    { "type": "telegram", "token_encrypted": authoritative }
8146                ]
8147            })
8148            .to_string(),
8149        )
8150        .unwrap();
8151
8152        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8153        assert_eq!(
8154            config.connect.platforms[0].token.as_deref(),
8155            Some("authoritative-secret")
8156        );
8157        // Unrelated field survives the strip.
8158        assert_eq!(config.http_proxy, "http://keep-me");
8159
8160        let config_json: serde_json::Value =
8161            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8162                .unwrap();
8163        assert!(
8164            config_json.get("connect").is_none(),
8165            "the stale legacy `connect` key must be stripped from config.json \
8166             immediately on load, not left for the next natural save"
8167        );
8168    }
8169
8170    /// #468 (follow-up to #457): a `.bak` generation that predates the #455
8171    /// split can still carry the legacy embedded `connect` sub-tree even
8172    /// after the LIVE config.json has long since been migrated (a clean
8173    /// config.json here, with no `connect` key at all, proves the sweep does
8174    /// not depend on the current-load migration path having just fired).
8175    /// Only the tainted generation is rewritten; every other key in it
8176    /// survives, and the rewrite strips exactly the `connect` key.
8177    #[test]
8178    fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
8179        let temp = TempHome::new();
8180        temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
8181        std::fs::write(
8182            temp.path.join("config.json.bak"),
8183            serde_json::json!({
8184                "http_proxy": "http://old",
8185                "connect": {
8186                    "platforms": [
8187                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
8188                    ]
8189                }
8190            })
8191            .to_string(),
8192        )
8193        .unwrap();
8194
8195        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8196        // The live config is unaffected — connect.json never existed and
8197        // config.json never had the key, so in-memory connect stays empty.
8198        assert!(config.connect.platforms.is_empty());
8199
8200        let bak: serde_json::Value = serde_json::from_str(
8201            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8202        )
8203        .unwrap();
8204        assert!(
8205            bak.get("connect").is_none(),
8206            "the legacy `connect` key must be stripped from the tainted .bak generation"
8207        );
8208        assert_eq!(
8209            bak["http_proxy"], "http://old",
8210            "every other key in the .bak generation survives the scrub byte-for-byte in content"
8211        );
8212    }
8213
8214    /// The sweep must touch EVERY rotated generation that carries the legacy
8215    /// key, not just `.bak` — an upgraded instance can have the taint several
8216    /// generations deep depending on how many saves happened since #455/#457
8217    /// shipped but before this fix.
8218    #[test]
8219    fn scrub_reaches_all_rotated_generations() {
8220        let temp = TempHome::new();
8221        temp.set_config_json(&serde_json::json!({}).to_string());
8222        for (gen_suffix, cipher) in [
8223            ("config.json.bak", "cipher-gen0"),
8224            ("config.json.bak.1", "cipher-gen1"),
8225            ("config.json.bak.2", "cipher-gen2"),
8226        ] {
8227            std::fs::write(
8228                temp.path.join(gen_suffix),
8229                serde_json::json!({
8230                    "connect": {
8231                        "platforms": [
8232                            { "type": "telegram", "token_encrypted": cipher }
8233                        ]
8234                    }
8235                })
8236                .to_string(),
8237            )
8238            .unwrap();
8239        }
8240
8241        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8242
8243        for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
8244            let value: serde_json::Value =
8245                serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
8246                    .unwrap();
8247            assert!(
8248                value.get("connect").is_none(),
8249                "{gen_suffix} must have its legacy `connect` key stripped"
8250            );
8251        }
8252    }
8253
8254    /// A `.bak` generation with NO legacy `connect` key must be left
8255    /// completely untouched by the sweep — not even a byte-identical
8256    /// rewrite — preserving the file's bytes/mtime exactly. This is the
8257    /// overwhelming common case (any backup created after #455/#457 shipped)
8258    /// and the whole point of the surgical, only-touch-what's-tainted
8259    /// approach: `.bak` files are the user's recovery net (#493) and
8260    /// shouldn't be churned by an unrelated sweep.
8261    #[test]
8262    fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
8263        let temp = TempHome::new();
8264        temp.set_config_json(&serde_json::json!({}).to_string());
8265        let bak_path = temp.path.join("config.json.bak");
8266        std::fs::write(
8267            &bak_path,
8268            serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
8269        )
8270        .unwrap();
8271
8272        let before_bytes = std::fs::read(&bak_path).unwrap();
8273        let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8274
8275        // A tiny sleep would make an mtime-changed assertion more robust, but
8276        // even without one, a same-mtime filesystem is the STRONGER
8277        // guarantee of "no write happened" — good enough on its own.
8278        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8279
8280        let after_bytes = std::fs::read(&bak_path).unwrap();
8281        let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8282        assert_eq!(
8283            before_bytes, after_bytes,
8284            "a .bak generation without a legacy `connect` key must not be rewritten at all"
8285        );
8286        assert_eq!(
8287            before_mtime, after_mtime,
8288            "no write means no mtime change either"
8289        );
8290    }
8291
8292    /// An unparsable `.bak` generation (corrupt/foreign content) must be
8293    /// skipped, not deleted and not guessed at — it's left exactly as found
8294    /// so an operator can inspect it by hand, matching the same fail-safe
8295    /// posture as the rest of the backup/quarantine machinery.
8296    #[test]
8297    fn scrub_skips_unparsable_backup_without_deleting_it() {
8298        let temp = TempHome::new();
8299        temp.set_config_json(&serde_json::json!({}).to_string());
8300        std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
8301
8302        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8303
8304        let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8305        assert_eq!(
8306            content, "{ not valid json",
8307            "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8308        );
8309    }
8310
8311    /// A missing generation (e.g. only `.bak` exists, no `.bak.1`/`.bak.2`
8312    /// yet) must not trip an error — it's the common case for a young
8313    /// install and the sweep should just skip straight past it.
8314    #[test]
8315    fn scrub_tolerates_missing_generations() {
8316        let temp = TempHome::new();
8317        temp.set_config_json(&serde_json::json!({}).to_string());
8318        // No .bak files at all.
8319        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8320        assert!(config.connect.platforms.is_empty());
8321        assert!(!temp.path.join("config.json.bak").exists());
8322    }
8323
8324    /// The scrub sweep must not interfere with normal backup rotation on
8325    /// subsequent saves — rotation keeps working exactly as before.
8326    #[test]
8327    fn scrub_does_not_break_backup_rotation() {
8328        let temp = TempHome::new();
8329        std::fs::write(
8330            temp.path.join("config.json"),
8331            serde_json::json!({
8332                "http_proxy": "http://proxy-v1",
8333                "connect": {
8334                    "platforms": [
8335                        { "type": "telegram", "token_encrypted": "legacy-cipher" }
8336                    ]
8337                }
8338            })
8339            .to_string(),
8340        )
8341        .unwrap();
8342        std::fs::write(
8343            temp.path.join("config.json.bak"),
8344            serde_json::json!({
8345                "http_proxy": "http://proxy-v0",
8346                "connect": {
8347                    "platforms": [
8348                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8349                    ]
8350                }
8351            })
8352            .to_string(),
8353        )
8354        .unwrap();
8355
8356        // Load triggers: migration of the live legacy key + the .bak sweep.
8357        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8358        let bak: serde_json::Value = serde_json::from_str(
8359            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8360        )
8361        .unwrap();
8362        assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8363
8364        // Rotation still works on a subsequent save: v_current -> .bak,
8365        // .bak(old) -> .bak.1.
8366        config.http_proxy = "http://proxy-v2".to_string();
8367        config.save_to_dir(temp.path.clone()).unwrap();
8368
8369        let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8370        assert!(
8371            new_bak.contains("proxy-v1"),
8372            ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8373        );
8374        let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8375        assert!(
8376            new_bak1.contains("proxy-v0"),
8377            ".bak.1 holds the scrubbed older generation after rotation"
8378        );
8379        assert!(
8380            !new_bak1.contains("legacy-bak-cipher"),
8381            "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8382             stripped secret"
8383        );
8384    }
8385
8386    #[test]
8387    fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8388        let temp = TempHome::new();
8389        temp.set_config_json("{}");
8390        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8391
8392        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8393        assert!(
8394            config.connect.platforms.is_empty(),
8395            "corrupt connect.json fails SAFE to an empty/inert connect config"
8396        );
8397
8398        let backup = connect_json_path(&temp).with_extension("json.bak");
8399        assert!(
8400            backup.exists(),
8401            "the corrupt connect.json is quarantined to connect.json.bak"
8402        );
8403        assert!(
8404            std::fs::read_to_string(backup)
8405                .unwrap()
8406                .contains("not valid json"),
8407            "the quarantined copy holds the bad content"
8408        );
8409        // #457: quarantine MOVES the corrupt file rather than copying it, so
8410        // the data dir doesn't end up with two copies of the same corrupt
8411        // content (the live `connect.json` and its `.bak`) sitting side by
8412        // side, which reads as confusing/ambiguous mid-incident.
8413        assert!(
8414            !connect_json_path(&temp).exists(),
8415            "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8416             connect.json should remain after quarantine"
8417        );
8418    }
8419
8420    #[test]
8421    fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8422        let temp = TempHome::new();
8423        // A legacy inline `connect` key is present too — it must NOT be used as a
8424        // fallback when connect.json is corrupt (security-sensitive: fail safe).
8425        temp.set_config_json(
8426            &serde_json::json!({
8427                "connect": {
8428                    "platforms": [
8429                        { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8430                    ]
8431                }
8432            })
8433            .to_string(),
8434        );
8435        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8436
8437        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8438        assert!(
8439            config.connect.platforms.is_empty(),
8440            "corrupt connect.json must not fall back to the legacy config.json copy"
8441        );
8442    }
8443
8444    #[test]
8445    fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8446        let temp = TempHome::new();
8447        let config = Config::create_default();
8448        assert!(config.connect.platforms.is_empty());
8449
8450        config
8451            .save_to_dir(temp.path.clone())
8452            .expect("save succeeds");
8453
8454        assert!(
8455            !connect_json_path(&temp).exists(),
8456            "an empty connect config with no pre-existing file must not create one"
8457        );
8458    }
8459
8460    #[test]
8461    fn connect_json_backed_up_before_overwrite() {
8462        let temp = TempHome::new();
8463        std::fs::write(
8464            connect_json_path(&temp),
8465            serde_json::json!({
8466                "platforms": [
8467                    { "type": "telegram", "token_encrypted": "old-cipher" }
8468                ]
8469            })
8470            .to_string(),
8471        )
8472        .unwrap();
8473
8474        let mut config = Config::create_default();
8475        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8476        config
8477            .save_to_dir(temp.path.clone())
8478            .expect("save succeeds");
8479
8480        let backup = connect_json_path(&temp).with_extension("json.bak");
8481        assert!(
8482            std::fs::read_to_string(backup)
8483                .unwrap()
8484                .contains("old-cipher"),
8485            "the previous connect.json is preserved as connect.json.bak before the overwrite"
8486        );
8487        let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8488        assert!(current.contains("new-cipher"));
8489    }
8490
8491    // ── Feishu adapter config fields (epic #447 phase 3, §2a) ───────────
8492
8493    #[test]
8494    fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8495        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8496        let temp = TempHome::new();
8497
8498        let mut config = Config::create_default();
8499        config.connect.platforms = vec![ConnectPlatformConfig {
8500            id: None,
8501            project_id: None,
8502            platform_type: "feishu".to_string(),
8503            token: None,
8504            token_encrypted: None,
8505            token_credential_ref: None,
8506            token_configured: false,
8507            app_id: Some("cli_real_app_id".to_string()),
8508            app_secret: Some("plain-app-secret".to_string()),
8509            app_secret_encrypted: None,
8510            app_secret_credential_ref: None,
8511            app_secret_configured: false,
8512            domain: Some("lark".to_string()),
8513            allow_from: vec!["ou_1".to_string()],
8514            admin_from: Vec::new(),
8515        }];
8516
8517        config
8518            .save_to_dir(temp.path.clone())
8519            .expect("save succeeds");
8520
8521        let connect_json: serde_json::Value =
8522            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8523                .unwrap();
8524        assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8525        assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8526        assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8527        assert!(
8528            connect_json["platforms"][0]["app_secret_encrypted"]
8529                .as_str()
8530                .is_some_and(|v| !v.is_empty()),
8531            "app_secret is persisted in its encrypted form in connect.json"
8532        );
8533        assert!(
8534            connect_json["platforms"][0].get("app_secret").is_none(),
8535            "the plaintext app_secret is never persisted (skip_serializing)"
8536        );
8537    }
8538
8539    #[test]
8540    fn load_hydrates_feishu_app_secret_from_encrypted() {
8541        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8542        let temp = TempHome::new();
8543
8544        let mut config = Config::create_default();
8545        config.connect.platforms = vec![ConnectPlatformConfig {
8546            id: None,
8547            project_id: None,
8548            platform_type: "feishu".to_string(),
8549            token: None,
8550            token_encrypted: None,
8551            token_credential_ref: None,
8552            token_configured: false,
8553            app_id: Some("cli_real_app_id".to_string()),
8554            app_secret: Some("plain-app-secret".to_string()),
8555            app_secret_encrypted: None,
8556            app_secret_credential_ref: None,
8557            app_secret_configured: false,
8558            domain: Some("lark".to_string()),
8559            allow_from: vec!["ou_1".to_string()],
8560            admin_from: Vec::new(),
8561        }];
8562        config
8563            .save_to_dir(temp.path.clone())
8564            .expect("save succeeds");
8565
8566        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8567        assert_eq!(reloaded.connect.platforms.len(), 1);
8568        assert_eq!(
8569            reloaded.connect.platforms[0].app_secret.as_deref(),
8570            Some("plain-app-secret"),
8571            "reload hydrates app_secret from app_secret_encrypted"
8572        );
8573        assert_eq!(
8574            reloaded.connect.platforms[0].app_id.as_deref(),
8575            Some("cli_real_app_id")
8576        );
8577        assert_eq!(
8578            reloaded.connect.platforms[0].domain.as_deref(),
8579            Some("lark")
8580        );
8581    }
8582
8583    #[test]
8584    fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8585        let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8586        let temp = TempHome::new();
8587        let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8588        std::fs::write(
8589            connect_json_path(&temp),
8590            serde_json::json!({
8591                "platforms": [
8592                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8593                ]
8594            })
8595            .to_string(),
8596        )
8597        .unwrap();
8598
8599        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8600
8601        assert_eq!(config.connect.platforms.len(), 1);
8602        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8603        assert_eq!(
8604            config.connect.platforms[0].token.as_deref(),
8605            Some("legacy-telegram-secret")
8606        );
8607        assert_eq!(
8608            config.connect.platforms[0].app_id, None,
8609            "a legacy entry with no Feishu fields deserializes them as None"
8610        );
8611        assert_eq!(config.connect.platforms[0].app_secret, None);
8612        assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8613        assert_eq!(config.connect.platforms[0].domain, None);
8614    }
8615
8616    #[test]
8617    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8618        let _lock = env_lock_acquire();
8619        let temp_home = TempHome::new();
8620        temp_home.set_config_json(
8621            r#"{
8622  "provider": "openai",
8623  "providers": {
8624    "openai": {
8625      "api_key": "sk-test",
8626      "model": "gpt-4o"
8627    }
8628  }
8629}"#,
8630        );
8631
8632        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8633        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8634
8635        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8636
8637        assert_eq!(
8638            config
8639                .providers
8640                .openai
8641                .as_ref()
8642                .and_then(|c| c.model.as_deref()),
8643            Some("gpt-4o")
8644        );
8645        assert!(
8646            config.http_proxy.is_empty(),
8647            "config should keep http_proxy empty when field is omitted"
8648        );
8649        assert!(
8650            config.https_proxy.is_empty(),
8651            "config should keep https_proxy empty when field is omitted"
8652        );
8653    }
8654
8655    #[test]
8656    fn get_memory_background_model_prefers_memory_specific_override() {
8657        let mut config = Config::default();
8658        config.features.provider_model_ref = false;
8659        config.provider = "openai".to_string();
8660        config.providers.openai = Some(OpenAIConfig {
8661            api_key: "test".to_string(),
8662            api_key_encrypted: None,
8663            credential_ref: None,
8664            base_url: None,
8665            model: Some("gpt-main".to_string()),
8666            fast_model: Some("gpt-fast".to_string()),
8667            vision_model: None,
8668            reasoning_effort: None,
8669            responses_only_models: vec![],
8670            request_overrides: None,
8671            extra: BTreeMap::new(),
8672            api_key_from_env: false,
8673        });
8674        config.memory.0 = Some(MemoryConfig {
8675            background_model: Some("memory-fast".to_string()),
8676            ..MemoryConfig::default()
8677        });
8678
8679        assert_eq!(
8680            config.get_memory_background_model().as_deref(),
8681            Some("memory-fast")
8682        );
8683    }
8684
8685    #[test]
8686    fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8687        // #373: the settings-PATCH serde round-trip drops every provider's
8688        // skip_serializing api_key; an env-sourced key (no ciphertext) can't be
8689        // re-hydrated, so it must be copied back from the live `current` config —
8690        // but an explicitly re-set key and non-env keys must NOT be touched.
8691        let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8692            api_key: api_key.to_string(),
8693            api_key_encrypted: None,
8694            credential_ref: None,
8695            base_url: None,
8696            model: None,
8697            fast_model: None,
8698            vision_model: None,
8699            reasoning_effort: None,
8700            responses_only_models: vec![],
8701            request_overrides: None,
8702            extra: BTreeMap::new(),
8703            api_key_from_env: from_env,
8704        };
8705
8706        // Env-sourced key dropped by the round-trip → restored.
8707        let mut current = Config::default();
8708        current.providers.openai = Some(openai("sk-env", true));
8709        let mut merged = Config::default();
8710        merged.providers.openai = Some(openai("", false)); // post-round-trip
8711        merged.preserve_env_sourced_provider_keys(&current);
8712        let got = merged.providers.openai.as_ref().unwrap();
8713        assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8714        assert!(got.api_key_from_env, "env flag restored");
8715
8716        // A key explicitly re-set by the patch is NOT overridden.
8717        let mut merged = Config::default();
8718        merged.providers.openai = Some(openai("sk-explicit", false));
8719        merged.preserve_env_sourced_provider_keys(&current);
8720        assert_eq!(
8721            merged.providers.openai.as_ref().unwrap().api_key,
8722            "sk-explicit",
8723            "explicit patch key must win"
8724        );
8725
8726        // A non-env key in current is NOT restored here (that's ciphertext hydration's job).
8727        let mut current_plain = Config::default();
8728        current_plain.providers.openai = Some(openai("sk-plain", false));
8729        let mut merged = Config::default();
8730        merged.providers.openai = Some(openai("", false));
8731        merged.preserve_env_sourced_provider_keys(&current_plain);
8732        assert!(
8733            merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8734            "non-env key must not be restored by this path"
8735        );
8736    }
8737
8738    #[test]
8739    fn refresh_preserves_ciphertext_when_plaintext_empty() {
8740        // #268: a provider whose stored ciphertext failed to decrypt at hydration
8741        // has an empty in-memory api_key. An unrelated later save must NOT null its
8742        // ciphertext — that would permanently drop a key the user never touched.
8743        let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8744            api_key: api_key.to_string(),
8745            api_key_encrypted: enc.map(str::to_string),
8746            credential_ref: None,
8747            base_url: None,
8748            model: None,
8749            fast_model: None,
8750            vision_model: None,
8751            reasoning_effort: None,
8752            responses_only_models: vec![],
8753            request_overrides: None,
8754            extra: BTreeMap::new(),
8755            api_key_from_env: false,
8756        };
8757
8758        // Empty plaintext + existing ciphertext → ciphertext preserved (the bug).
8759        let mut config = Config::default();
8760        config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8761        config
8762            .refresh_provider_api_keys_encrypted()
8763            .expect("refresh");
8764        assert_eq!(
8765            config
8766                .providers
8767                .openai
8768                .as_ref()
8769                .unwrap()
8770                .api_key_encrypted
8771                .as_deref(),
8772            Some("preexisting-ciphertext"),
8773            "existing ciphertext must be preserved when plaintext is empty"
8774        );
8775
8776        // Empty plaintext + no ciphertext → stays None (nothing to preserve).
8777        let mut config = Config::default();
8778        config.providers.openai = Some(openai("", None));
8779        config
8780            .refresh_provider_api_keys_encrypted()
8781            .expect("refresh");
8782        assert!(
8783            config
8784                .providers
8785                .openai
8786                .as_ref()
8787                .unwrap()
8788                .api_key_encrypted
8789                .is_none(),
8790            "no key + no ciphertext should stay None"
8791        );
8792
8793        // Non-empty plaintext → (re)encrypted to a fresh, non-empty ciphertext.
8794        let mut config = Config::default();
8795        config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8796        config
8797            .refresh_provider_api_keys_encrypted()
8798            .expect("refresh");
8799        let enc = config
8800            .providers
8801            .openai
8802            .as_ref()
8803            .unwrap()
8804            .api_key_encrypted
8805            .clone()
8806            .expect("ciphertext present");
8807        assert!(
8808            !enc.is_empty() && enc != "stale-ciphertext",
8809            "plaintext re-encrypted"
8810        );
8811    }
8812
8813    #[test]
8814    fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8815        // #516: `save_to_dir` refreshes ciphertext only on its save-time clone,
8816        // so a provider instance created over HTTP stays plaintext-only in the
8817        // live config. Serializing that live config (as the settings-PATCH
8818        // merge does) drops the `skip_serializing` plaintext and the key is
8819        // gone. `refresh_encrypted_secrets` on the live config closes the gap.
8820        let mut config = Config::default();
8821        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8822            "provider_type": "openai",
8823            "api_key": "sk-instance-live",
8824        }))
8825        .expect("valid instance");
8826        config
8827            .provider_instances
8828            .insert("work".to_string(), instance);
8829
8830        config.refresh_encrypted_secrets().expect("refresh");
8831        assert!(
8832            config.provider_instances["work"]
8833                .api_key_encrypted
8834                .is_some(),
8835            "live config must hold ciphertext after refresh"
8836        );
8837
8838        // The build_merged_config-style round-trip.
8839        let value = serde_json::to_value(&config).expect("serialize");
8840        let mut back: Config = serde_json::from_value(value).expect("deserialize");
8841        assert!(
8842            back.provider_instances["work"].api_key.is_empty(),
8843            "plaintext is skip_serializing"
8844        );
8845        back.hydrate_provider_instance_api_keys_from_encrypted();
8846        assert_eq!(
8847            back.provider_instances["work"].api_key, "sk-instance-live",
8848            "key must be recoverable from the round-tripped ciphertext"
8849        );
8850    }
8851
8852    #[test]
8853    fn get_memory_background_model_falls_back_to_provider_fast_model() {
8854        let mut config = Config::default();
8855        config.features.provider_model_ref = false;
8856        config.provider = "openai".to_string();
8857        config.providers.openai = Some(OpenAIConfig {
8858            api_key: "test".to_string(),
8859            api_key_encrypted: None,
8860            credential_ref: None,
8861            base_url: None,
8862            model: Some("gpt-main".to_string()),
8863            fast_model: Some("gpt-fast".to_string()),
8864            vision_model: None,
8865            reasoning_effort: None,
8866            responses_only_models: vec![],
8867            request_overrides: None,
8868            extra: BTreeMap::new(),
8869            api_key_from_env: false,
8870        });
8871
8872        assert_eq!(
8873            config.get_memory_background_model().as_deref(),
8874            Some("gpt-fast")
8875        );
8876    }
8877
8878    #[test]
8879    fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8880        let mut config = Config::default();
8881        config.features.provider_model_ref = false;
8882        config.provider = "openai".to_string();
8883        config.providers.openai = Some(OpenAIConfig {
8884            api_key: "sk-stale".to_string(),
8885            model: Some("legacy-main".to_string()),
8886            fast_model: Some("legacy-fast".to_string()),
8887            vision_model: Some("legacy-vision".to_string()),
8888            reasoning_effort: Some(ReasoningEffort::Low),
8889            ..OpenAIConfig::default()
8890        });
8891        let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8892            "provider_type": "openai",
8893            "model": "instance-main",
8894            "fast_model": "instance-fast",
8895            "vision_model": "instance-vision",
8896            "reasoning_effort": "high",
8897            "enabled": true
8898        }))
8899        .unwrap();
8900        instance.api_key = "sk-instance".to_string();
8901        config
8902            .provider_instances
8903            .insert("work".to_string(), instance);
8904        config.default_provider_instance = Some("work".to_string());
8905
8906        assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8907        assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8908        assert_eq!(
8909            config.get_memory_background_model().as_deref(),
8910            Some("instance-fast")
8911        );
8912        assert_eq!(
8913            config.get_task_summary_model().as_deref(),
8914            Some("instance-fast")
8915        );
8916        assert_eq!(
8917            config.get_vision_model().as_deref(),
8918            Some("instance-vision")
8919        );
8920        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8921    }
8922
8923    #[test]
8924    fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8925        let mut config = Config::default();
8926        config.features.provider_model_ref = true;
8927        config.provider_instances.insert(
8928            "compatibility-default".to_string(),
8929            serde_json::from_value(serde_json::json!({
8930                "provider_type": "openai",
8931                "reasoning_effort": "low",
8932                "enabled": true
8933            }))
8934            .unwrap(),
8935        );
8936        config.provider_instances.insert(
8937            "chat-provider".to_string(),
8938            serde_json::from_value(serde_json::json!({
8939                "provider_type": "anthropic",
8940                "reasoning_effort": "high",
8941                "enabled": true
8942            }))
8943            .unwrap(),
8944        );
8945        config.default_provider_instance = Some("compatibility-default".to_string());
8946        config.defaults = Some(
8947            serde_json::from_value(serde_json::json!({
8948                "chat": { "provider": "chat-provider", "model": "claude" }
8949            }))
8950            .unwrap(),
8951        );
8952
8953        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8954
8955        config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8956        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8957    }
8958
8959    #[test]
8960    fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8961        let _provider =
8962            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8963        let _openai_key = crate::test_support::override_runtime_env_var(
8964            "BAMBOO_OPENAI_API_KEY",
8965            Some("sk-runtime-env-instance"),
8966        );
8967        let _anthropic_key =
8968            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8969        let _gemini_key =
8970            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8971
8972        let mut config = Config::default();
8973        *config.providers_mut() = ProviderConfigs::default();
8974        for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8975            let mut extra = serde_json::Map::new();
8976            if from_environment {
8977                extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8978            }
8979            extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8980            extra.insert("enabled".to_string(), serde_json::json!(true));
8981            config.provider_instances.insert(
8982                id.to_string(),
8983                serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8984            );
8985        }
8986
8987        config.apply_runtime_env_overrides();
8988
8989        assert_eq!(
8990            config.default_provider_instance.as_deref(),
8991            Some("a-marked"),
8992            "type override selects the lexicographically first enabled instance"
8993        );
8994        assert_eq!(
8995            config.provider_instances["a-marked"].api_key,
8996            "sk-runtime-env-instance"
8997        );
8998        assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
8999        assert!(
9000            config.providers().openai.is_none(),
9001            "instance-mode env hydration must not recreate a legacy alias"
9002        );
9003
9004        config.refresh_encrypted_secrets().unwrap();
9005        let serialized = serde_json::to_string(&config).unwrap();
9006        assert!(!serialized.contains("sk-runtime-env-instance"));
9007        assert!(
9008            config.provider_instances["a-marked"]
9009                .api_key_encrypted
9010                .is_none(),
9011            "runtime env plaintext must not be encrypted into ordinary config"
9012        );
9013    }
9014
9015    #[test]
9016    fn runtime_provider_override_prefers_exact_instance_id() {
9017        let _provider =
9018            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
9019        let _openai_key =
9020            crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
9021        let _anthropic_key =
9022            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
9023        let _gemini_key =
9024            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9025        let mut config = Config::default();
9026        for id in ["work", "personal"] {
9027            config.provider_instances.insert(
9028                id.to_string(),
9029                serde_json::from_value(serde_json::json!({
9030                    "provider_type": "openai",
9031                    "enabled": true
9032                }))
9033                .unwrap(),
9034            );
9035        }
9036
9037        config.apply_runtime_env_overrides();
9038        assert_eq!(
9039            config.default_provider_instance.as_deref(),
9040            Some("personal")
9041        );
9042    }
9043
9044    #[test]
9045    fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
9046        let mut config = Config::default();
9047        config.features.provider_model_ref = false;
9048        config.provider = "openai".to_string();
9049        config.providers.openai = Some(OpenAIConfig {
9050            api_key: "test".to_string(),
9051            api_key_encrypted: None,
9052            credential_ref: None,
9053            base_url: None,
9054            model: Some("gpt-main".to_string()),
9055            fast_model: None,
9056            vision_model: None,
9057            reasoning_effort: None,
9058            responses_only_models: vec![],
9059            request_overrides: None,
9060            extra: BTreeMap::new(),
9061            api_key_from_env: false,
9062        });
9063
9064        assert_eq!(
9065            config.get_memory_background_model().as_deref(),
9066            Some("gpt-main")
9067        );
9068    }
9069
9070    #[test]
9071    fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
9072        let legacy = serde_json::json!({
9073            "memory": MemoryConfig {
9074                background_model: Some("dream-fast".to_string()),
9075                summary_target_ratio: 0.20,
9076                summary_safe_window_percent: 80,
9077                auto_dream_enabled: true,
9078                auto_dream_interval_secs: 900,
9079                project_prompt_injection: false,
9080                relevant_recall: false,
9081                relevant_recall_rerank: true,
9082                project_first_dream: false,
9083                ledger_agenda_injection: false,
9084                ledger_gardener_enabled: false,
9085                ledger_gardener_interval_secs: 7_200,
9086                ledger_distillation_enabled: false,
9087                dream_refine_mode: true,
9088                gardener_enabled: true,
9089                gardener_interval_secs: 3_600,
9090                gardener_volume_trigger: 40,
9091                gardener_max_splits_per_run: 4,
9092                gardener_min_sections: 7,
9093                dedup_gardener_enabled: true,
9094                dedup_gardener_min_score: 0.7,
9095                dedup_gardener_max_merges_per_run: 3,
9096                memory_active_capacity: 500,
9097                capacity_max_archivals_per_run: 10,
9098                granularity_freshness_gardener_enabled: false,
9099            }
9100        });
9101        let config: Config = serde_json::from_value(legacy).unwrap();
9102
9103        let serialized = serde_json::to_value(&config).expect("config should serialize");
9104        assert!(serialized.get("memory").is_some());
9105        let round_tripped: Config = serde_json::from_value(serialized).unwrap();
9106        assert!(round_tripped
9107            .memory()
9108            .as_ref()
9109            .is_some_and(|memory| memory.dream_refine_mode));
9110        let memory = config.memory.as_ref().expect("memory config should exist");
9111        assert!(memory.auto_dream_enabled);
9112        assert!(!memory.project_prompt_injection);
9113        assert!(!memory.relevant_recall);
9114        assert!(memory.relevant_recall_rerank);
9115        assert!(!memory.project_first_dream);
9116        assert!(memory.dream_refine_mode);
9117        assert!(memory.gardener_enabled);
9118        assert_eq!(memory.gardener_interval_secs, 3_600);
9119        assert_eq!(memory.gardener_volume_trigger, 40);
9120        assert_eq!(memory.gardener_max_splits_per_run, 4);
9121        assert_eq!(memory.gardener_min_sections, 7);
9122        assert!(memory.dedup_gardener_enabled);
9123        assert_eq!(memory.dedup_gardener_min_score, 0.7);
9124        assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
9125        assert_eq!(memory.memory_active_capacity, 500);
9126        assert_eq!(memory.capacity_max_archivals_per_run, 10);
9127        assert!(!memory.granularity_freshness_gardener_enabled);
9128    }
9129
9130    /// L5: capacity is OFF by default (0 = unbounded) — an opt-in feature.
9131    #[test]
9132    fn memory_active_capacity_defaults_off() {
9133        assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
9134        assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
9135        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9136        let memory = parsed.memory.as_ref().unwrap();
9137        assert_eq!(memory.memory_active_capacity, 0);
9138        assert_eq!(
9139            memory.capacity_max_archivals_per_run, 50,
9140            "omitted field takes the serde default fn"
9141        );
9142    }
9143
9144    #[test]
9145    fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
9146        let defaults = MemoryConfig::default();
9147        assert_eq!(defaults.summary_target_ratio, 0.20);
9148        assert_eq!(defaults.summary_safe_window_percent, 80);
9149
9150        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9151        let memory = parsed.memory.as_ref().expect("memory present");
9152        assert_eq!(memory.summary_target_ratio, 0.20);
9153        assert_eq!(memory.summary_safe_window_percent, 80);
9154    }
9155
9156    /// L4: the maintenance integrators are ON by default — both via
9157    /// `MemoryConfig::default()` AND when a config file omits the flags entirely
9158    /// (serde `default = fn`, not the bare `#[serde(default)]` = `false`).
9159    #[test]
9160    fn memory_maintenance_integrators_default_on() {
9161        let defaults = MemoryConfig::default();
9162        assert!(defaults.auto_dream_enabled);
9163        assert!(defaults.gardener_enabled);
9164        assert!(defaults.dedup_gardener_enabled);
9165        assert_eq!(defaults.gardener_volume_trigger, 25);
9166
9167        // A config that mentions `memory` but omits the flags must still be ON.
9168        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9169        let memory = parsed.memory.as_ref().expect("memory present");
9170        assert!(
9171            memory.auto_dream_enabled,
9172            "auto_dream on when field omitted"
9173        );
9174        assert!(memory.gardener_enabled, "gardener on when field omitted");
9175        assert!(
9176            memory.dedup_gardener_enabled,
9177            "dedup gardener on when field omitted"
9178        );
9179        // An explicit opt-out is still honored.
9180        let opted_out: Config =
9181            serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
9182        assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
9183    }
9184
9185    #[test]
9186    fn memory_config_env_overrides_prompt_flags() {
9187        let _lock = env_lock_acquire();
9188        let temp_home = TempHome::new();
9189        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9190        let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
9191        let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
9192        let _relevant_recall_rerank =
9193            EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
9194        let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
9195
9196        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9197        let memory = config
9198            .memory
9199            .as_ref()
9200            .expect("memory config should be created by env overrides");
9201        assert!(!memory.project_prompt_injection);
9202        assert!(!memory.relevant_recall);
9203        assert!(memory.relevant_recall_rerank);
9204        assert!(!memory.project_first_dream);
9205    }
9206
9207    #[test]
9208    fn provider_api_keys_injected_from_env_and_never_persisted() {
9209        let _lock = env_lock_acquire();
9210        let temp_home = TempHome::new();
9211        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9212        let _anthropic = crate::test_support::override_runtime_env_var(
9213            "BAMBOO_ANTHROPIC_API_KEY",
9214            Some("sk-ant-from-env"),
9215        );
9216        let _openai = crate::test_support::override_runtime_env_var(
9217            "BAMBOO_OPENAI_API_KEY",
9218            Some("sk-oai-from-env"),
9219        );
9220        let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9221
9222        // No config.json on disk → the providers are created from the env keys
9223        // alone (#253: deploy without a plaintext api_key in a mounted file).
9224        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9225        assert_eq!(
9226            config
9227                .providers
9228                .anthropic
9229                .as_ref()
9230                .expect("anthropic created from env")
9231                .api_key,
9232            "sk-ant-from-env"
9233        );
9234        assert_eq!(
9235            config
9236                .providers
9237                .openai
9238                .as_ref()
9239                .expect("openai created from env")
9240                .api_key,
9241            "sk-oai-from-env"
9242        );
9243        // An unset provider is not fabricated.
9244        assert!(config.providers.gemini.is_none());
9245
9246        // The real "never persisted" guarantee: saving the config must NOT bake
9247        // the env key into config.json — not as plaintext AND not re-encrypted
9248        // into `api_key_encrypted` (which save's `refresh_provider_api_keys_encrypted`
9249        // would otherwise do). This is what actually happens on the server when
9250        // any unrelated setting is saved / on a fabric-reconcile boot.
9251        config
9252            .save_to_dir(temp_home.path.clone())
9253            .expect("save config");
9254        let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
9255            .expect("read persisted config.json");
9256        assert!(
9257            !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
9258            "env key must not be persisted as plaintext"
9259        );
9260        let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
9261        assert!(
9262            disk_json["providers"]["anthropic"]
9263                .get("api_key_encrypted")
9264                .is_none(),
9265            "env-sourced anthropic key must not be re-encrypted into config.json"
9266        );
9267        assert!(
9268            disk_json["providers"]["openai"]
9269                .get("api_key_encrypted")
9270                .is_none(),
9271            "env-sourced openai key must not be re-encrypted into config.json"
9272        );
9273
9274        // And once the env vars are gone, a reload from that same dir has no key
9275        // (nothing was persisted).
9276        drop(_anthropic);
9277        drop(_openai);
9278        let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
9279        assert!(reloaded
9280            .providers
9281            .anthropic
9282            .as_ref()
9283            .map(|a| a.api_key.is_empty())
9284            .unwrap_or(true));
9285    }
9286
9287    #[test]
9288    fn get_default_work_area_path_expands_tilde_and_requires_directory() {
9289        let _lock = env_lock_acquire();
9290        let temp_home = TempHome::new();
9291        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9292        let target = temp_home.path.join("workspace-default");
9293        std::fs::create_dir_all(&target).expect("default work area dir should exist");
9294
9295        let mut config = Config::default();
9296        config.default_work_area.replace(DefaultWorkAreaConfig {
9297            path: Some("~/workspace-default".to_string()),
9298        });
9299
9300        assert_eq!(config.get_default_work_area_path(), Some(target));
9301    }
9302
9303    #[test]
9304    fn get_default_work_area_path_returns_none_for_missing_directory() {
9305        let _lock = env_lock_acquire();
9306        let temp_home = TempHome::new();
9307        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9308
9309        let mut config = Config::default();
9310        config.default_work_area.replace(DefaultWorkAreaConfig {
9311            path: Some("~/missing-default-work-area".to_string()),
9312        });
9313
9314        assert!(config.get_default_work_area_path().is_none());
9315    }
9316
9317    #[test]
9318    fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9319        let mut config = Config::default();
9320        config.tools.disabled = vec![
9321            "  read_file  ".to_string(),
9322            "".to_string(),
9323            "read_file".to_string(),
9324            "bash".to_string(),
9325            "default::getCurrentDir".to_string(),
9326            "default::applyPatch".to_string(),
9327            "default::custom_tool".to_string(),
9328            "mcp__alpha__inspect".to_string(),
9329        ];
9330
9331        config.normalize_tool_settings();
9332
9333        assert_eq!(
9334            config.tools.disabled,
9335            vec![
9336                "bash",
9337                "default::applyPatch",
9338                "default::custom_tool",
9339                "default::getCurrentDir",
9340                "mcp__alpha__inspect",
9341                "read_file"
9342            ]
9343        );
9344    }
9345
9346    #[test]
9347    fn config_load_preserves_disabled_references_for_catalog_resolution() {
9348        let _lock = env_lock_acquire();
9349        let temp_home = TempHome::new();
9350        temp_home.set_config_json(
9351            r#"{
9352  "tools": {
9353    "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9354  }
9355}"#,
9356        );
9357
9358        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9359        assert_eq!(
9360            config.tools.disabled,
9361            vec!["bash", "default::getCurrentDir", "read_file"]
9362        );
9363        assert!(config.disabled_tool_references().contains("bash"));
9364        assert!(config.disabled_tool_references().contains("read_file"));
9365        assert!(config
9366            .disabled_tool_references()
9367            .contains("default::getCurrentDir"));
9368        assert_eq!(
9369            config.disabled_tool_names(),
9370            BTreeSet::from([
9371                "Bash".to_string(),
9372                "GetCurrentDir".to_string(),
9373                "Read".to_string()
9374            ])
9375        );
9376    }
9377
9378    #[test]
9379    fn normalize_skill_settings_trims_dedupes_and_sorts() {
9380        let mut config = Config::default();
9381        config.skills.disabled = vec![
9382            " pdf ".to_string(),
9383            "".to_string(),
9384            "pdf".to_string(),
9385            "skill-creator".to_string(),
9386        ];
9387
9388        config.normalize_skill_settings();
9389
9390        assert_eq!(
9391            config.skills.disabled,
9392            vec!["pdf".to_string(), "skill-creator".to_string()]
9393        );
9394    }
9395
9396    #[test]
9397    fn config_load_reads_disabled_skills_as_normalized_ids() {
9398        let _lock = env_lock_acquire();
9399        let temp_home = TempHome::new();
9400        temp_home.set_config_json(
9401            r#"{
9402  "skills": {
9403    "disabled": [" pdf ", "skill-creator", "pdf", ""]
9404  }
9405}"#,
9406        );
9407
9408        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9409        assert_eq!(
9410            config.skills.disabled,
9411            vec!["pdf".to_string(), "skill-creator".to_string()]
9412        );
9413        assert!(config.disabled_skill_ids().contains("pdf"));
9414        assert!(config.disabled_skill_ids().contains("skill-creator"));
9415    }
9416
9417    #[test]
9418    fn test_server_config_defaults() {
9419        let _lock = env_lock_acquire();
9420        let temp_home = TempHome::new();
9421
9422        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9423        assert_eq!(config.server.port, 9562);
9424        assert_eq!(config.server.bind, "127.0.0.1");
9425        assert_eq!(config.server.workers, 10);
9426        assert!(config.server.static_dir.is_none());
9427    }
9428
9429    #[test]
9430    fn test_server_addr() {
9431        let mut config = Config::default();
9432        config.server.port = 9000;
9433        config.server.bind = "0.0.0.0".to_string();
9434        assert_eq!(config.server_addr(), "0.0.0.0:9000");
9435    }
9436
9437    #[test]
9438    fn test_env_var_overrides() {
9439        let _lock = env_lock_acquire();
9440        let temp_home = TempHome::new();
9441
9442        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9443        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9444        let _provider =
9445            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9446
9447        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9448        assert_eq!(config.server.port, 9999);
9449        assert_eq!(config.server.bind, "192.168.1.1");
9450        assert_eq!(config.provider, "openai");
9451    }
9452
9453    #[test]
9454    fn test_config_save_and_load() {
9455        let _lock = env_lock_acquire();
9456        let temp_home = TempHome::new();
9457
9458        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9459        config.server.port = 9000;
9460        config.server.bind = "0.0.0.0".to_string();
9461        config.provider = "anthropic".to_string();
9462
9463        // Save
9464        config
9465            .save_to_dir(temp_home.path.clone())
9466            .expect("Failed to save config");
9467
9468        // Load again
9469        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9470
9471        // Verify
9472        assert_eq!(loaded.server.port, 9000);
9473        assert_eq!(loaded.server.bind, "0.0.0.0");
9474        assert_eq!(loaded.provider, "anthropic");
9475    }
9476
9477    #[test]
9478    fn modular_root_and_public_serde_preserve_legacy_shape() {
9479        let _lock = env_lock_acquire();
9480        let temp_home = TempHome::new();
9481        let input = serde_json::json!({
9482            "http_proxy": "http://proxy.example",
9483            "provider": "openai",
9484            "mcp": { "servers": [] },
9485            "future_extension": { "enabled": true },
9486            "memory": { "background_model": "memory-model" },
9487            "subagents": { "max_concurrent": 7 },
9488            "providers": { "openai": { "model": "chat-model" } }
9489        });
9490
9491        let config: Config = serde_json::from_value(input).unwrap();
9492        let public = serde_json::to_value(&config).unwrap();
9493        assert_eq!(public["http_proxy"], "http://proxy.example");
9494        assert!(public.get("mcp").is_none());
9495        assert!(public.get("mcpServers").is_some());
9496        assert_eq!(public["future_extension"]["enabled"], true);
9497        assert_eq!(public["memory"]["background_model"], "memory-model");
9498        assert_eq!(public["subagents"]["max_concurrent"], 7);
9499        assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9500
9501        let round_tripped: Config = serde_json::from_value(public).unwrap();
9502        assert_eq!(
9503            round_tripped
9504                .memory()
9505                .as_ref()
9506                .unwrap()
9507                .background_model
9508                .as_deref(),
9509            Some("memory-model")
9510        );
9511        assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9512        assert_eq!(
9513            round_tripped
9514                .providers()
9515                .openai
9516                .as_ref()
9517                .unwrap()
9518                .model
9519                .as_deref(),
9520            Some("chat-model")
9521        );
9522        assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9523
9524        round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9525        let persisted: Value =
9526            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9527                .unwrap();
9528        assert_eq!(persisted["http_proxy"], "http://proxy.example");
9529        assert!(persisted.get("mcpServers").is_some());
9530        assert_eq!(persisted["future_extension"]["enabled"], true);
9531        assert!(persisted.get("memory").is_none());
9532        assert!(persisted.get("subagents").is_none());
9533        assert!(persisted.get("providers").is_none());
9534        for internal_section_name in [
9535            "network",
9536            "provider_routing",
9537            "model_behavior",
9538            "tooling",
9539            "workspace",
9540            "execution",
9541            "integrations",
9542            "plugin_security",
9543        ] {
9544            assert!(persisted.get(internal_section_name).is_none());
9545        }
9546    }
9547
9548    #[test]
9549    fn config_decrypts_proxy_auth_from_encrypted_field() {
9550        let _lock = env_lock_acquire();
9551        let temp_home = TempHome::new();
9552
9553        // Use a stable encryption key so this test doesn't depend on host identifiers.
9554        let key_guard = crate::encryption::set_test_encryption_key([
9555            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9556            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9557            0x1c, 0x1d, 0x1e, 0x1f,
9558        ]);
9559
9560        let auth = ProxyAuth {
9561            username: "user".to_string(),
9562            password: "pass".to_string(),
9563        };
9564        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9565        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9566
9567        temp_home.set_config_json(&format!(
9568            r#"{{
9569  "http_proxy": "http://proxy.example.com:8080",
9570  "proxy_auth_encrypted": "{encrypted}"
9571}}"#
9572        ));
9573        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9574        let loaded_auth = config
9575            .proxy_auth
9576            .as_ref()
9577            .expect("proxy auth should be hydrated");
9578        assert_eq!(loaded_auth.username, "user");
9579        assert_eq!(loaded_auth.password, "pass");
9580        drop(key_guard);
9581    }
9582
9583    #[test]
9584    fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9585        let _lock = env_lock_acquire();
9586        let temp_home = TempHome::new();
9587
9588        // Use a stable encryption key so this test doesn't depend on host identifiers.
9589        let key_guard = crate::encryption::set_test_encryption_key([
9590            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9591            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9592            0x1c, 0x1d, 0x1e, 0x1f,
9593        ]);
9594
9595        let auth = ProxyAuth {
9596            username: "user".to_string(),
9597            password: "pass".to_string(),
9598        };
9599        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9600        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9601
9602        // Simulate older Bodhi/Tauri persisted config keys.
9603        temp_home.set_config_json(&format!(
9604            r#"{{
9605  "http_proxy": "http://proxy.example.com:8080",
9606  "http_proxy_auth_encrypted": "{encrypted}",
9607  "https_proxy_auth_encrypted": "{encrypted}"
9608}}"#
9609        ));
9610
9611        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9612        let loaded_auth = config
9613            .proxy_auth
9614            .as_ref()
9615            .expect("proxy auth should be hydrated");
9616        assert_eq!(loaded_auth.username, "user");
9617        assert_eq!(loaded_auth.password, "pass");
9618        drop(key_guard);
9619    }
9620
9621    #[test]
9622    fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9623        let _lock = env_lock_acquire();
9624        let temp_home = TempHome::new();
9625
9626        // Use a stable encryption key so this test doesn't depend on host identifiers.
9627        let key_guard = crate::encryption::set_test_encryption_key([
9628            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9629            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9630            0x1c, 0x1d, 0x1e, 0x1f,
9631        ]);
9632
9633        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9634        config.proxy_auth = Some(ProxyAuth {
9635            username: "user".to_string(),
9636            password: "pass".to_string(),
9637        });
9638        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9639        assert!(error
9640            .to_string()
9641            .contains("isolated credential transaction"));
9642        let path = temp_home.path.join("config.json");
9643        assert!(
9644            !path.exists(),
9645            "a rejected unisolated secret must not create config.json"
9646        );
9647        drop(key_guard);
9648    }
9649
9650    #[test]
9651    fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9652        let _lock = env_lock_acquire();
9653        let temp_home = TempHome::new();
9654        let mut config = Config::default();
9655        config.env_vars.push(EnvVarEntry {
9656            name: "TOKEN".to_string(),
9657            value: String::new(),
9658            secret: true,
9659            value_encrypted: None,
9660            credential_ref: None,
9661            configured: true,
9662            description: None,
9663        });
9664        let path = temp_home.path.join("config.json");
9665
9666        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9667        assert!(error
9668            .to_string()
9669            .contains("isolated credential transaction"));
9670        assert!(
9671            !path.exists(),
9672            "a rejected dangling ref must not create config.json"
9673        );
9674
9675        let original = br#"{"preserve":"original"}"#;
9676        std::fs::write(&path, original).unwrap();
9677        config.save_to_dir(temp_home.path.clone()).unwrap_err();
9678        assert_eq!(
9679            std::fs::read(&path).unwrap(),
9680            original,
9681            "a rejected dangling ref must not modify an existing config.json"
9682        );
9683    }
9684
9685    #[test]
9686    fn config_save_persists_provider_reference_and_isolates_plaintext() {
9687        let _lock = env_lock_acquire();
9688        let temp_home = TempHome::new();
9689
9690        // Use a stable encryption key so this test doesn't depend on host identifiers.
9691        let key_guard = crate::encryption::set_test_encryption_key([
9692            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9693            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9694            0x1c, 0x1d, 0x1e, 0x1f,
9695        ]);
9696
9697        let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9698        crate::CredentialStore::open(&temp_home.path)
9699            .replace(
9700                reference.clone(),
9701                "sk-test-provider-key",
9702                crate::CredentialSource::User,
9703                0,
9704            )
9705            .unwrap();
9706        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9707        config.provider = "openai".to_string();
9708        config.providers.openai = Some(OpenAIConfig {
9709            api_key: "sk-test-provider-key".to_string(),
9710            api_key_encrypted: None,
9711            credential_ref: Some(reference),
9712            base_url: None,
9713            model: None,
9714            fast_model: None,
9715            vision_model: None,
9716            reasoning_effort: None,
9717            responses_only_models: vec![],
9718            request_overrides: None,
9719            extra: Default::default(),
9720            api_key_from_env: false,
9721        });
9722
9723        config
9724            .save_to_dir(temp_home.path.clone())
9725            .expect("save should persist provider reference");
9726
9727        let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9728            .expect("read providers.json");
9729        assert!(
9730            !content.contains("api_key_encrypted"),
9731            "providers.json must not store provider ciphertext"
9732        );
9733        assert!(
9734            !content.contains("\"api_key\""),
9735            "providers.json should not store plaintext provider keys"
9736        );
9737
9738        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9739        let openai = loaded
9740            .providers
9741            .openai
9742            .as_ref()
9743            .expect("openai config should be present");
9744        assert_eq!(openai.api_key, "sk-test-provider-key");
9745
9746        drop(key_guard);
9747    }
9748
9749    #[test]
9750    fn config_save_persists_mcp_servers_in_mainstream_format() {
9751        let _lock = env_lock_acquire();
9752        let temp_home = TempHome::new();
9753
9754        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9755
9756        let mut env = std::collections::HashMap::new();
9757        env.insert("TOKEN".to_string(), "supersecret".to_string());
9758
9759        config.mcp.servers = vec![
9760            bamboo_domain::mcp_config::McpServerConfig {
9761                id: "stdio-secret".to_string(),
9762                name: None,
9763                enabled: true,
9764                transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9765                    bamboo_domain::mcp_config::StdioConfig {
9766                        command: "echo".to_string(),
9767                        args: vec![],
9768                        cwd: None,
9769                        env,
9770                        env_encrypted: std::collections::HashMap::new(),
9771                        env_credential_refs: std::collections::HashMap::new(),
9772                        startup_timeout_ms: 5000,
9773                    },
9774                ),
9775                request_timeout_ms: 5000,
9776                healthcheck_interval_ms: 1000,
9777                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9778                allowed_tools: vec![],
9779                denied_tools: vec![],
9780            },
9781            bamboo_domain::mcp_config::McpServerConfig {
9782                id: "http-secret".to_string(),
9783                name: None,
9784                enabled: true,
9785                transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9786                    bamboo_domain::mcp_config::StreamableHttpConfig {
9787                        url: "http://localhost:8080/http".to_string(),
9788                        headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9789                            name: "Authorization".to_string(),
9790                            value: "Bearer token123".to_string(),
9791                            value_encrypted: None,
9792                            credential_ref: None,
9793                        }],
9794                        connect_timeout_ms: 5000,
9795                    },
9796                ),
9797                request_timeout_ms: 5000,
9798                healthcheck_interval_ms: 1000,
9799                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9800                allowed_tools: vec![],
9801                denied_tools: vec![],
9802            },
9803        ];
9804
9805        config
9806            .save_to_dir(temp_home.path.clone())
9807            .expect("save should persist MCP servers");
9808
9809        let content =
9810            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9811        assert!(
9812            content.contains("\"mcpServers\""),
9813            "config.json should store MCP servers under the mainstream 'mcpServers' key"
9814        );
9815        assert!(
9816            content.contains("supersecret"),
9817            "config.json should persist MCP stdio env in mainstream format"
9818        );
9819        assert!(
9820            content.contains("Bearer token123"),
9821            "config.json should persist MCP Streamable HTTP headers in mainstream format"
9822        );
9823        assert!(
9824            !content.contains("\"env_encrypted\""),
9825            "config.json should not persist legacy env_encrypted fields"
9826        );
9827        assert!(
9828            !content.contains("\"value_encrypted\""),
9829            "config.json should not persist legacy value_encrypted fields"
9830        );
9831
9832        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9833        let stdio = loaded
9834            .mcp
9835            .servers
9836            .iter()
9837            .find(|s| s.id == "stdio-secret")
9838            .expect("stdio server should exist");
9839        match &stdio.transport {
9840            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9841                assert_eq!(
9842                    stdio.env.get("TOKEN").map(|s| s.as_str()),
9843                    Some("supersecret")
9844                );
9845            }
9846            _ => panic!("Expected stdio transport"),
9847        }
9848
9849        let http = loaded
9850            .mcp
9851            .servers
9852            .iter()
9853            .find(|s| s.id == "http-secret")
9854            .expect("http server should exist");
9855        match &http.transport {
9856            bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9857                assert_eq!(http.headers[0].value, "Bearer token123");
9858            }
9859            _ => panic!("Expected Streamable HTTP transport"),
9860        }
9861    }
9862
9863    #[test]
9864    fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9865        let _lock = env_lock_acquire();
9866        let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9867        let temp_home = TempHome::new();
9868        let env_plaintext = "mcp-root-env-plaintext-597";
9869        let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9870        let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9871        std::fs::write(
9872            temp_home.path.join("config.json"),
9873            serde_json::to_vec_pretty(&serde_json::json!({
9874                "features": {"provider_model_ref": true},
9875                "mcpServers": {
9876                    "stdio-root": {
9877                        "command": "unused-disabled-command",
9878                        "env": {"TOKEN": env_plaintext},
9879                        "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9880                        "env_credential_refs": {
9881                            "TOKEN": "mcp.stdio-root.env_TOKEN"
9882                        }
9883                    },
9884                    "http-root": {
9885                        "url": "https://example.test/mcp",
9886                        "transport_kind": "streamable_http",
9887                        "headers": {"Authorization": header_plaintext},
9888                        "header_credential_refs": {
9889                            "Authorization": "mcp.http-root.header_Authorization"
9890                        }
9891                    }
9892                }
9893            }))
9894            .unwrap(),
9895        )
9896        .unwrap();
9897        std::fs::write(
9898            temp_home.path.join("mcp.json"),
9899            serde_json::to_vec_pretty(&serde_json::json!({
9900                "schema_version": 1,
9901                "revision": 7,
9902                "data": {
9903                    "stdio-root": {
9904                        "command": "unused-disabled-command",
9905                        "enabled": false,
9906                        "env_encrypted": {"TOKEN": legacy_ciphertext},
9907                        "request_timeout_ms": 100,
9908                        "healthcheck_interval_ms": 100
9909                    },
9910                    "http-root": {
9911                        "url": "https://example.test/mcp",
9912                        "transport_kind": "streamable_http",
9913                        "enabled": false,
9914                        "headers": {"Authorization": header_plaintext},
9915                        "request_timeout_ms": 100,
9916                        "healthcheck_interval_ms": 100
9917                    }
9918                }
9919            }))
9920            .unwrap(),
9921        )
9922        .unwrap();
9923
9924        crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9925        let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9926            temp_home.path.join("mcp.json"),
9927            1,
9928        )
9929        .load()
9930        .unwrap()
9931        .unwrap();
9932        let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9933        config.mcp = stored.data;
9934        config
9935            .hydrate_mcp_credentials_from_store(&temp_home.path)
9936            .unwrap();
9937
9938        let public = serde_json::to_value(&config.mcp).unwrap();
9939        let rendered_public = public.to_string();
9940        assert!(rendered_public.contains(env_plaintext));
9941        assert!(rendered_public.contains(header_plaintext));
9942        let compatible: bamboo_domain::mcp_config::McpConfig =
9943            serde_json::from_value(public).unwrap();
9944        assert_eq!(compatible.servers.len(), 2);
9945
9946        config.features.provider_model_ref = !config.features.provider_model_ref;
9947        config.save_to_dir(temp_home.path.clone()).unwrap();
9948        config.features.provider_model_ref = !config.features.provider_model_ref;
9949        config.save_to_dir(temp_home.path.clone()).unwrap();
9950
9951        for entry in std::fs::read_dir(&temp_home.path)
9952            .unwrap()
9953            .filter_map(Result::ok)
9954        {
9955            let name = entry.file_name().to_string_lossy().to_string();
9956            if name == "credentials.json" || entry.path().is_dir() {
9957                continue;
9958            }
9959            if name == "config.json"
9960                || name == "mcp.json"
9961                || name.contains(".bak")
9962                || name.starts_with("config-credential-migration")
9963            {
9964                let bytes = std::fs::read(entry.path()).unwrap();
9965                let content = String::from_utf8_lossy(&bytes);
9966                assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9967                assert!(
9968                    !content.contains(header_plaintext),
9969                    "secret leaked to {name}"
9970                );
9971                assert!(
9972                    !content.contains(&legacy_ciphertext),
9973                    "legacy ciphertext leaked to {name}"
9974                );
9975            }
9976        }
9977    }
9978
9979    // ── Env vars lifecycle tests ──────────────────────────────
9980
9981    #[test]
9982    fn env_vars_as_map_includes_only_non_empty_values() {
9983        let mut config = Config::default();
9984        config.env_vars.extend([
9985            EnvVarEntry {
9986                name: "A".to_string(),
9987                value: "val_a".to_string(),
9988                secret: false,
9989                value_encrypted: None,
9990                credential_ref: None,
9991                configured: true,
9992                description: None,
9993            },
9994            EnvVarEntry {
9995                name: "B".to_string(),
9996                value: "".to_string(), // empty → should be excluded
9997                secret: true,
9998                value_encrypted: None,
9999                credential_ref: None,
10000                configured: false,
10001                description: None,
10002            },
10003            EnvVarEntry {
10004                name: "C".to_string(),
10005                value: "  ".to_string(), // whitespace-only → excluded
10006                secret: false,
10007                value_encrypted: None,
10008                credential_ref: None,
10009                configured: true,
10010                description: None,
10011            },
10012            EnvVarEntry {
10013                name: "D".to_string(),
10014                value: "val_d".to_string(),
10015                secret: true,
10016                value_encrypted: Some("enc".to_string()),
10017                credential_ref: None,
10018                configured: true,
10019                description: Some("desc".to_string()),
10020            },
10021        ]);
10022
10023        let map = config.env_vars_as_map();
10024        assert_eq!(map.len(), 2);
10025        assert_eq!(map.get("A"), Some(&"val_a".to_string()));
10026        assert_eq!(map.get("D"), Some(&"val_d".to_string()));
10027        assert!(!map.contains_key("B"));
10028        assert!(!map.contains_key("C"));
10029    }
10030
10031    #[test]
10032    fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
10033        let mut config = Config::default();
10034        config.env_vars.extend([
10035            EnvVarEntry {
10036                name: "PLAIN".to_string(),
10037                value: "visible".to_string(),
10038                secret: false,
10039                value_encrypted: None,
10040                credential_ref: None,
10041                configured: true,
10042                description: None,
10043            },
10044            EnvVarEntry {
10045                name: "SECRET".to_string(),
10046                value: "hidden_value".to_string(),
10047                secret: true,
10048                value_encrypted: Some("enc_data".to_string()),
10049                credential_ref: None,
10050                configured: true,
10051                description: None,
10052            },
10053        ]);
10054
10055        config.sanitize_env_vars_for_disk();
10056
10057        assert_eq!(config.env_vars[0].value, "visible"); // plain kept
10058        assert_eq!(config.env_vars[1].value, ""); // secret cleared
10059    }
10060
10061    #[test]
10062    fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
10063        let mut config = Config::default();
10064        config.env_vars.extend([
10065            EnvVarEntry {
10066                name: "OPEN".to_string(),
10067                value: "val".to_string(),
10068                secret: false,
10069                value_encrypted: None,
10070                credential_ref: None,
10071                configured: true,
10072                description: None,
10073            },
10074            EnvVarEntry {
10075                name: "HIDDEN".to_string(),
10076                value: "real_secret".to_string(),
10077                secret: true,
10078                value_encrypted: Some("enc".to_string()),
10079                credential_ref: None,
10080                configured: true,
10081                description: None,
10082            },
10083        ]);
10084
10085        config.sanitize_env_vars_for_disk();
10086
10087        // Plain value untouched
10088        assert_eq!(config.env_vars[0].value, "val");
10089        // Secret plaintext and legacy ciphertext are removed.
10090        assert_eq!(config.env_vars[1].value, "");
10091        assert!(config.env_vars[1].value_encrypted.is_none());
10092    }
10093
10094    #[test]
10095    fn legacy_env_ciphertext_is_read_but_never_serialized() {
10096        let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
10097        let mut config: Config = serde_json::from_value(serde_json::json!({
10098            "env_vars": [{
10099                "name": "TOKEN",
10100                "secret": true,
10101                "value_encrypted": ciphertext
10102            }]
10103        }))
10104        .unwrap();
10105        config.hydrate_env_vars_from_encrypted();
10106        assert_eq!(config.env_vars[0].value, "my-secret-token");
10107        let serialized = serde_json::to_value(&config).unwrap();
10108        assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
10109    }
10110
10111    #[test]
10112    fn configured_env_ref_missing_from_store_fails_closed() {
10113        let dir = tempfile::tempdir().unwrap();
10114        let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
10115        let mut config = Config::default();
10116        config.env_vars.push(EnvVarEntry {
10117            name: "TOKEN".to_string(),
10118            value: String::new(),
10119            secret: true,
10120            value_encrypted: None,
10121            credential_ref: Some(reference),
10122            configured: true,
10123            description: None,
10124        });
10125        let error = config
10126            .hydrate_env_var_credentials_from_store(dir.path())
10127            .unwrap_err();
10128        assert!(error
10129            .to_string()
10130            .contains("referenced env credential is unavailable"));
10131        assert!(config.env_vars[0].configured);
10132        assert!(config.env_vars[0].value.is_empty());
10133    }
10134
10135    #[test]
10136    fn configured_notification_ref_missing_from_store_fails_closed() {
10137        let dir = tempfile::tempdir().unwrap();
10138        let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
10139        let mut config = Config::default();
10140        config.notifications.ntfy.credential_ref = Some(reference);
10141        config.notifications.ntfy.configured = true;
10142
10143        let error = config
10144            .hydrate_notification_credentials_from_store(dir.path())
10145            .unwrap_err();
10146
10147        assert!(error
10148            .to_string()
10149            .contains("referenced ntfy credential is unavailable"));
10150        assert!(config.notifications.ntfy.configured);
10151        assert!(config.notifications.ntfy.token.is_none());
10152    }
10153
10154    #[test]
10155    fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
10156        let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
10157        let dir = tempfile::tempdir().unwrap();
10158        let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
10159        crate::CredentialStore::open(dir.path())
10160            .replace(
10161                reference.clone(),
10162                "exclusive-notification-secret",
10163                crate::CredentialSource::User,
10164                0,
10165            )
10166            .unwrap();
10167
10168        let mut config = Config::default();
10169        config.notifications.ntfy.credential_ref = Some(reference.clone());
10170        config.notifications.ntfy.configured = true;
10171        config.proxy_auth_credential_ref = Some(reference.clone());
10172        let error = config
10173            .hydrate_notification_credentials_from_store(dir.path())
10174            .unwrap_err();
10175        let rendered = error.to_string();
10176        assert!(rendered
10177            .contains("notification credential reference is shared by another config consumer"));
10178        assert!(!rendered.contains(reference.as_str()));
10179        assert!(config.notifications.ntfy.token.is_none());
10180
10181        config.proxy_auth_credential_ref = None;
10182        config
10183            .hydrate_notification_credentials_from_store(dir.path())
10184            .unwrap();
10185        assert_eq!(
10186            config.notifications.ntfy.token.as_deref(),
10187            Some("exclusive-notification-secret")
10188        );
10189    }
10190
10191    #[test]
10192    fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
10193        let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
10194        let dir = tempfile::tempdir().unwrap();
10195        let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
10196        let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
10197        let bytes = serde_json::to_vec_pretty(&serde_json::json!({
10198            "notifications": {
10199                "ntfy": { "enabled": true, "token_encrypted": ntfy },
10200                "bark": { "enabled": true, "device_key_encrypted": bark }
10201            }
10202        }))
10203        .unwrap();
10204        std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
10205        std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
10206
10207        let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
10208
10209        assert!(loaded.notifications.ntfy.token.is_none());
10210        assert!(loaded.notifications.bark.device_key.is_none());
10211        assert_eq!(
10212            std::fs::read(dir.path().join("config.json")).unwrap(),
10213            bytes
10214        );
10215    }
10216
10217    #[test]
10218    fn publish_and_current_env_vars_round_trip() {
10219        // `publish_env_vars` REPLACES the process-global env-vars cache
10220        // wholesale, so every test that touches that cache must hold the
10221        // crate-wide env lock. This test didn't (issue #486): running
10222        // concurrently with a lock-holding cache test (e.g.
10223        // `from_data_dir_without_publish_does_not_clobber_global_cache`) it
10224        // wiped that test's just-seeded marker out of the cache mid-assert —
10225        // and its own 10x retry loop below was itself a symptom of losing
10226        // the same race in the other direction. With the lock held, one
10227        // publish is deterministic.
10228        let _lock = crate::test_support::env_cache_lock_acquire();
10229        let mut config = Config::default();
10230        config.env_vars.extend([EnvVarEntry {
10231            name: "TEST_PUBLISH".to_string(),
10232            value: "pub_value".to_string(),
10233            secret: false,
10234            value_encrypted: None,
10235            credential_ref: None,
10236            configured: true,
10237            description: None,
10238        }]);
10239
10240        config.publish_env_vars();
10241        assert_eq!(
10242            Config::current_env_vars()
10243                .get("TEST_PUBLISH")
10244                .map(String::as_str),
10245            Some("pub_value")
10246        );
10247    }
10248
10249    #[test]
10250    fn broker_token_round_trips_encrypt_sanitize_hydrate() {
10251        let mut config = Config::default();
10252        config.subagents.broker = Some(BrokerClientConfig {
10253            endpoint: "ws://127.0.0.1:9600".to_string(),
10254            token: "super-secret-token".to_string(),
10255            token_encrypted: None,
10256            credential_ref: None,
10257            configured: false,
10258        });
10259
10260        // Persist path: encrypt then sanitize (what save_to_dir does).
10261        config.refresh_broker_token_encrypted().unwrap();
10262        config.sanitize_broker_token_for_disk();
10263        let broker = config.subagents.broker.as_ref().unwrap();
10264        assert!(broker.token.is_empty(), "plaintext cleared for disk");
10265        assert!(broker.token_encrypted.is_some(), "ciphertext stored");
10266        assert_ne!(
10267            broker.token_encrypted.as_deref(),
10268            Some("super-secret-token")
10269        );
10270
10271        // Load path: hydrate restores plaintext.
10272        config.hydrate_broker_token_from_encrypted();
10273        assert_eq!(
10274            config.subagents.broker.as_ref().unwrap().token,
10275            "super-secret-token"
10276        );
10277    }
10278
10279    #[test]
10280    fn broker_token_empty_refresh_preserves_ciphertext() {
10281        // A redacted round-trip (token empty) must not wipe the stored ciphertext.
10282        let mut config = Config::default();
10283        config.subagents.broker = Some(BrokerClientConfig {
10284            endpoint: "ws://h:9600".to_string(),
10285            token: String::new(),
10286            token_encrypted: Some("existing-cipher".to_string()),
10287            credential_ref: None,
10288            configured: false,
10289        });
10290        config.refresh_broker_token_encrypted().unwrap();
10291        assert_eq!(
10292            config
10293                .subagents
10294                .broker
10295                .as_ref()
10296                .unwrap()
10297                .token_encrypted
10298                .as_deref(),
10299            Some("existing-cipher"),
10300        );
10301    }
10302
10303    #[test]
10304    fn notifications_config_defaults_when_key_missing() {
10305        // Additive/back-compat: an absent `notifications` key must deserialize
10306        // to the built-in defaults (desktop auto, ntfy/bark disabled).
10307        let config: Config = serde_json::from_str("{}").expect("empty object parses");
10308        assert_eq!(config.notifications, NotificationsConfig::default());
10309        assert_eq!(config.notifications.desktop.enabled, None);
10310        assert!(!config.notifications.ntfy.enabled);
10311        assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10312        assert_eq!(config.notifications.ntfy.token, None);
10313        assert!(!config.notifications.bark.enabled);
10314        assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10315        assert_eq!(config.notifications.bark.device_key, None);
10316    }
10317
10318    #[test]
10319    fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10320        let mut config = Config::default();
10321        config.notifications.ntfy = NtfyChannelConfig {
10322            enabled: true,
10323            base_url: "https://ntfy.sh".to_string(),
10324            topic: "bamboo-alerts".to_string(),
10325            token: Some("tk_super_secret".to_string()),
10326            token_encrypted: None,
10327            credential_ref: None,
10328            configured: false,
10329        };
10330
10331        // Legacy compatibility path: retain a readable ciphertext until the
10332        // credential migration moves the secret into the isolated store.
10333        config.refresh_notifications_encrypted().unwrap();
10334        assert!(config.notifications.ntfy.token_encrypted.is_some());
10335        assert_ne!(
10336            config.notifications.ntfy.token_encrypted.as_deref(),
10337            Some("tk_super_secret")
10338        );
10339
10340        // Neither plaintext nor legacy ciphertext is serializable.
10341        let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10342        assert!(
10343            !json.contains("tk_super_secret"),
10344            "plaintext token must never be serialized"
10345        );
10346        assert!(!json.contains("token_encrypted"));
10347
10348        // Legacy load path restores plaintext for migration.
10349        config.notifications.ntfy.token = None;
10350        config.hydrate_notifications_from_encrypted();
10351        assert_eq!(
10352            config.notifications.ntfy.token.as_deref(),
10353            Some("tk_super_secret")
10354        );
10355    }
10356
10357    #[test]
10358    fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10359        let mut config = Config::default();
10360        config.notifications.bark = BarkChannelConfig {
10361            enabled: true,
10362            base_url: "https://api.day.app".to_string(),
10363            device_key: Some("dk_super_secret".to_string()),
10364            device_key_encrypted: None,
10365            credential_ref: None,
10366            configured: false,
10367        };
10368
10369        config.refresh_notifications_encrypted().unwrap();
10370        assert!(config.notifications.bark.device_key_encrypted.is_some());
10371        assert_ne!(
10372            config.notifications.bark.device_key_encrypted.as_deref(),
10373            Some("dk_super_secret")
10374        );
10375
10376        let json = serde_json::to_string(&config.notifications.bark).unwrap();
10377        assert!(
10378            !json.contains("dk_super_secret"),
10379            "plaintext device key must never be serialized"
10380        );
10381        assert!(!json.contains("device_key_encrypted"));
10382
10383        config.notifications.bark.device_key = None;
10384        config.hydrate_notifications_from_encrypted();
10385        assert_eq!(
10386            config.notifications.bark.device_key.as_deref(),
10387            Some("dk_super_secret")
10388        );
10389    }
10390
10391    #[test]
10392    fn notification_secrets_empty_refresh_preserves_ciphertext() {
10393        // The legacy compatibility helper retains already-loaded ciphertext;
10394        // ordinary persistence sanitizes it before writing.
10395        let mut config = Config::default();
10396        config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10397        config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10398
10399        config.refresh_notifications_encrypted().unwrap();
10400
10401        assert_eq!(
10402            config.notifications.ntfy.token_encrypted.as_deref(),
10403            Some("existing-ntfy-cipher")
10404        );
10405        assert_eq!(
10406            config.notifications.bark.device_key_encrypted.as_deref(),
10407            Some("existing-bark-cipher")
10408        );
10409    }
10410
10411    #[test]
10412    fn hydrate_skips_non_secret_entries() {
10413        let mut config = Config::default();
10414        config.env_vars.extend([EnvVarEntry {
10415            name: "PLAIN".to_string(),
10416            value: "original".to_string(),
10417            secret: false,
10418            value_encrypted: Some("should-be-ignored".to_string()),
10419            credential_ref: None,
10420            configured: true,
10421            description: None,
10422        }]);
10423
10424        config.hydrate_env_vars_from_encrypted();
10425        // Non-secret entry should keep its original value
10426        assert_eq!(config.env_vars[0].value, "original");
10427    }
10428
10429    #[test]
10430    fn default_config_has_empty_env_vars() {
10431        // `Config::default()` is a pure in-memory constructor (no disk read, no
10432        // env overrides), so this is independent of the developer's
10433        // `~/.bamboo/config.json` — no temp-dir isolation needed. Directly
10434        // asserts the #38 invariant that default() does not touch the filesystem.
10435        assert!(Config::default().env_vars.is_empty());
10436    }
10437
10438    #[test]
10439    fn serde_round_trip_with_env_vars() {
10440        let mut config = Config::default();
10441        config.env_vars.extend([
10442            EnvVarEntry {
10443                name: "KEY1".to_string(),
10444                value: "val1".to_string(),
10445                secret: false,
10446                value_encrypted: None,
10447                credential_ref: None,
10448                configured: true,
10449                description: Some("First key".to_string()),
10450            },
10451            EnvVarEntry {
10452                name: "KEY2".to_string(),
10453                value: "".to_string(), // on-disk secret has no plaintext
10454                secret: true,
10455                value_encrypted: Some("enc123".to_string()),
10456                credential_ref: None,
10457                configured: true,
10458                description: None,
10459            },
10460        ]);
10461
10462        let json = serde_json::to_string(&config).unwrap();
10463        let restored: Config = serde_json::from_str(&json).unwrap();
10464
10465        assert_eq!(restored.env_vars.len(), 2);
10466        assert_eq!(restored.env_vars[0].name, "KEY1");
10467        assert_eq!(restored.env_vars[0].value, "val1");
10468        assert!(!restored.env_vars[0].secret);
10469        assert_eq!(restored.env_vars[1].name, "KEY2");
10470        assert!(restored.env_vars[1].secret);
10471        assert!(restored.env_vars[1].value_encrypted.is_none());
10472    }
10473
10474    // ---- defaults.* model resolution tests ----
10475
10476    #[test]
10477    // fields set conditionally below
10478    #[allow(clippy::field_reassign_with_default)]
10479    fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10480        let mut config = Config::default();
10481        config.provider = "openai".to_string();
10482        config.providers.openai = Some(OpenAIConfig {
10483            api_key: "test".to_string(),
10484            api_key_encrypted: None,
10485            credential_ref: None,
10486            base_url: None,
10487            model: Some("legacy-gpt-4o".to_string()),
10488            fast_model: None,
10489            vision_model: None,
10490            reasoning_effort: None,
10491            responses_only_models: vec![],
10492            request_overrides: None,
10493            extra: Default::default(),
10494            api_key_from_env: false,
10495        });
10496        config.features.provider_model_ref = true;
10497        config.defaults = Some(DefaultsConfig {
10498            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10499            fast: None,
10500            task_summary: None,
10501            vision: None,
10502            memory_background: None,
10503            planning: None,
10504            search: None,
10505            code_review: None,
10506            sub_agent: None,
10507            subagent_models: Default::default(),
10508        });
10509
10510        assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10511    }
10512
10513    #[test]
10514    // fields set conditionally below
10515    #[allow(clippy::field_reassign_with_default)]
10516    fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10517        let mut config = Config::default();
10518        config.provider = "openai".to_string();
10519        config.providers.openai = Some(OpenAIConfig {
10520            api_key: "test".to_string(),
10521            api_key_encrypted: None,
10522            credential_ref: None,
10523            base_url: None,
10524            model: Some("legacy-gpt-4o".to_string()),
10525            fast_model: None,
10526            vision_model: None,
10527            reasoning_effort: None,
10528            responses_only_models: vec![],
10529            request_overrides: None,
10530            extra: Default::default(),
10531            api_key_from_env: false,
10532        });
10533        config.features.provider_model_ref = false;
10534        config.defaults = Some(DefaultsConfig {
10535            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10536            fast: None,
10537            task_summary: None,
10538            vision: None,
10539            memory_background: None,
10540            planning: None,
10541            search: None,
10542            code_review: None,
10543            sub_agent: None,
10544            subagent_models: Default::default(),
10545        });
10546
10547        assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10548    }
10549
10550    #[test]
10551    // fields set conditionally below
10552    #[allow(clippy::field_reassign_with_default)]
10553    fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10554        let mut config = Config::default();
10555        config.provider = "openai".to_string();
10556        config.providers.openai = Some(OpenAIConfig {
10557            api_key: "test".to_string(),
10558            api_key_encrypted: None,
10559            credential_ref: None,
10560            base_url: None,
10561            model: Some("gpt-4o".to_string()),
10562            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10563            vision_model: None,
10564            reasoning_effort: None,
10565            responses_only_models: vec![],
10566            request_overrides: None,
10567            extra: Default::default(),
10568            api_key_from_env: false,
10569        });
10570        config.features.provider_model_ref = true;
10571        config.defaults = Some(DefaultsConfig {
10572            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10573            fast: Some(bamboo_domain::ProviderModelRef::new(
10574                "anthropic",
10575                "claude-3-5-haiku",
10576            )),
10577            task_summary: None,
10578            vision: None,
10579            memory_background: None,
10580            planning: None,
10581            search: None,
10582            code_review: None,
10583            sub_agent: None,
10584            subagent_models: Default::default(),
10585        });
10586
10587        assert_eq!(
10588            config.get_fast_model(),
10589            Some("claude-3-5-haiku".to_string())
10590        );
10591    }
10592
10593    #[test]
10594    // fields set conditionally below
10595    #[allow(clippy::field_reassign_with_default)]
10596    fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10597        let mut config = Config::default();
10598        config.provider = "openai".to_string();
10599        config.providers.openai = Some(OpenAIConfig {
10600            api_key: "test".to_string(),
10601            api_key_encrypted: None,
10602            credential_ref: None,
10603            base_url: None,
10604            model: Some("gpt-4o".to_string()),
10605            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10606            vision_model: None,
10607            reasoning_effort: None,
10608            responses_only_models: vec![],
10609            request_overrides: None,
10610            extra: Default::default(),
10611            api_key_from_env: false,
10612        });
10613        config.features.provider_model_ref = false;
10614        config.defaults = Some(DefaultsConfig {
10615            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10616            fast: Some(bamboo_domain::ProviderModelRef::new(
10617                "anthropic",
10618                "claude-3-5-haiku",
10619            )),
10620            task_summary: None,
10621            vision: None,
10622            memory_background: None,
10623            planning: None,
10624            search: None,
10625            code_review: None,
10626            sub_agent: None,
10627            subagent_models: Default::default(),
10628        });
10629
10630        assert_eq!(
10631            config.get_fast_model(),
10632            Some("legacy-gpt-4o-mini".to_string())
10633        );
10634    }
10635
10636    #[test]
10637    // fields set conditionally below
10638    #[allow(clippy::field_reassign_with_default)]
10639    fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10640        let mut config = Config::default();
10641        config.provider = "openai".to_string();
10642        config.features.provider_model_ref = true;
10643        config.defaults = Some(DefaultsConfig {
10644            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10645            fast: None,
10646            task_summary: None,
10647            vision: None,
10648            memory_background: None,
10649            planning: None,
10650            search: None,
10651            code_review: None,
10652            sub_agent: None,
10653            subagent_models: Default::default(),
10654        });
10655
10656        assert_eq!(
10657            config.get_fast_model(),
10658            Some("claude-3-7-sonnet".to_string())
10659        );
10660    }
10661
10662    #[test]
10663    // fields set conditionally below
10664    #[allow(clippy::field_reassign_with_default)]
10665    fn get_memory_background_model_prefers_defaults_memory_background() {
10666        let mut config = Config::default();
10667        config.provider = "openai".to_string();
10668        config.providers.openai = Some(OpenAIConfig {
10669            api_key: "test".to_string(),
10670            api_key_encrypted: None,
10671            credential_ref: None,
10672            base_url: None,
10673            model: Some("gpt-4o".to_string()),
10674            fast_model: Some("gpt-4o-mini".to_string()),
10675            vision_model: None,
10676            reasoning_effort: None,
10677            responses_only_models: vec![],
10678            request_overrides: None,
10679            extra: Default::default(),
10680            api_key_from_env: false,
10681        });
10682        config.features.provider_model_ref = true;
10683        config.defaults = Some(DefaultsConfig {
10684            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10685            fast: Some(bamboo_domain::ProviderModelRef::new(
10686                "openai",
10687                "gpt-4o-mini",
10688            )),
10689            task_summary: None,
10690            vision: None,
10691            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10692                "anthropic",
10693                "claude-3-5-haiku",
10694            )),
10695            planning: None,
10696            search: None,
10697            code_review: None,
10698            sub_agent: None,
10699            subagent_models: Default::default(),
10700        });
10701
10702        assert_eq!(
10703            config.get_memory_background_model(),
10704            Some("claude-3-5-haiku".to_string())
10705        );
10706    }
10707
10708    #[test]
10709    // fields set conditionally below
10710    #[allow(clippy::field_reassign_with_default)]
10711    fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10712        let mut config = Config::default();
10713        config.provider = "openai".to_string();
10714        config.features.provider_model_ref = true;
10715        config.defaults = Some(DefaultsConfig {
10716            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10717            fast: Some(bamboo_domain::ProviderModelRef::new(
10718                "anthropic",
10719                "claude-3-5-haiku",
10720            )),
10721            task_summary: None,
10722            vision: None,
10723            memory_background: None,
10724            planning: None,
10725            search: None,
10726            code_review: None,
10727            sub_agent: None,
10728            subagent_models: Default::default(),
10729        });
10730
10731        assert_eq!(
10732            config.get_memory_background_model(),
10733            Some("claude-3-5-haiku".to_string())
10734        );
10735    }
10736
10737    #[test]
10738    // fields set conditionally below
10739    #[allow(clippy::field_reassign_with_default)]
10740    fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10741        let mut config = Config::default();
10742        config.provider = "openai".to_string();
10743        config.providers.openai = Some(OpenAIConfig {
10744            api_key: "test".to_string(),
10745            api_key_encrypted: None,
10746            credential_ref: None,
10747            base_url: None,
10748            model: Some("gpt-4o".to_string()),
10749            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10750            vision_model: None,
10751            reasoning_effort: None,
10752            responses_only_models: vec![],
10753            request_overrides: None,
10754            extra: Default::default(),
10755            api_key_from_env: false,
10756        });
10757        config.features.provider_model_ref = false;
10758        config.defaults = Some(DefaultsConfig {
10759            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10760            fast: Some(bamboo_domain::ProviderModelRef::new(
10761                "anthropic",
10762                "claude-3-5-haiku",
10763            )),
10764            task_summary: None,
10765            vision: None,
10766            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10767                "anthropic",
10768                "claude-3-5-haiku",
10769            )),
10770            planning: None,
10771            search: None,
10772            code_review: None,
10773            sub_agent: None,
10774            subagent_models: Default::default(),
10775        });
10776
10777        assert_eq!(
10778            config.get_memory_background_model(),
10779            Some("legacy-gpt-4o-mini".to_string())
10780        );
10781    }
10782
10783    // -------------------------------------------------------------------
10784    // `is_host_trusted` — plugin source-trust host allowlist (component
10785    // matching, not raw string-prefix matching; see the function's own docs
10786    // for the bypasses this closes).
10787    // -------------------------------------------------------------------
10788
10789    #[test]
10790    fn is_host_trusted_requires_https_scheme() {
10791        let hosts = vec!["github.com/bigduu/".to_string()];
10792        assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10793        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10794    }
10795
10796    #[test]
10797    fn is_host_trusted_is_case_insensitive_on_both_sides() {
10798        // A lowercase URL host against a mixed-case config entry...
10799        let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10800        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10801        // ...and a mixed-case URL host against a lowercase config entry.
10802        let hosts = vec!["github.com/bigduu/".to_string()];
10803        assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10804    }
10805
10806    #[test]
10807    fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10808        let hosts = vec!["trusted.example.com".to_string()];
10809        assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10810        // Both demonstrated bypasses of a raw string-prefix match: gluing a
10811        // longer attacker-controlled label onto the trusted host, with or
10812        // without a separating dot.
10813        assert!(!is_host_trusted(
10814            "https://trusted.example.com.evil.com/x",
10815            &hosts
10816        ));
10817        assert!(!is_host_trusted(
10818            "https://trusted.example.comevil.com/x",
10819            &hosts
10820        ));
10821    }
10822
10823    #[test]
10824    fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10825        // No trailing slash on the config entry's path component.
10826        let hosts = vec!["github.com/bigduu/".to_string()];
10827        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10828        assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10829    }
10830
10831    #[test]
10832    fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10833        let hosts = vec!["example.com".to_string()];
10834        assert!(is_host_trusted("https://example.com/", &hosts));
10835        assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10836        // Still only that exact host — a bare-host entry must not become a
10837        // blanket "any host containing this string" match.
10838        assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10839        assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10840    }
10841
10842    #[test]
10843    fn is_host_trusted_uses_the_real_host_not_userinfo() {
10844        let hosts = vec!["github.com/bigduu/".to_string()];
10845        // `user@host` userinfo does not change the actual host.
10846        assert!(is_host_trusted(
10847            "https://someuser@github.com/bigduu/x",
10848            &hosts
10849        ));
10850        // A decoy host placed in the userinfo position must not be mistaken
10851        // for the real host — the real host here is `evil.com`.
10852        assert!(!is_host_trusted(
10853            "https://github.com@evil.com/bigduu/",
10854            &hosts
10855        ));
10856    }
10857
10858    #[test]
10859    fn is_host_trusted_ignores_an_explicit_port() {
10860        let hosts = vec!["github.com/bigduu/".to_string()];
10861        assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10862    }
10863
10864    #[test]
10865    fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10866        let hosts = vec!["github.com/bigduu/".to_string()];
10867        assert!(!is_host_trusted("not a url at all", &hosts));
10868        assert!(!is_host_trusted("", &hosts));
10869        assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); // no scheme
10870    }
10871
10872    #[test]
10873    fn is_host_trusted_normalizes_dot_segments_before_matching() {
10874        let hosts = vec!["github.com/bigduu/".to_string()];
10875        // `Url::parse` resolves `..` segments before `path()` is ever
10876        // consulted, so this cannot be used to escape the trusted prefix.
10877        assert!(!is_host_trusted(
10878            "https://github.com/bigduu/../evil/x",
10879            &hosts
10880        ));
10881        // A `..` that stays under the trusted prefix once resolved is fine.
10882        assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10883    }
10884
10885    #[test]
10886    fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10887        let mut config = Config::default();
10888        config.plugin_trust.trusted_hosts = vec![
10889            "  GitHub.com/BigDuu/ ".to_string(),
10890            "".to_string(),
10891            "   ".to_string(),
10892            "Example.COM".to_string(),
10893        ];
10894        config.normalize_plugin_trust_settings();
10895        assert_eq!(
10896            config.plugin_trust.trusted_hosts,
10897            vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10898        );
10899    }
10900
10901    // -----------------------------------------------------------------
10902    // `plugin_trust.enforcement` — the persistent, config-level form of the
10903    // `--insecure` escape hatch.
10904    // -----------------------------------------------------------------
10905
10906    #[test]
10907    fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10908        // A fresh `Config::default()` (nothing on disk at all).
10909        let config = Config::default();
10910        assert_eq!(
10911            config.plugin_trust.enforcement,
10912            PluginTrustEnforcement::Strict
10913        );
10914        assert!(!config.plugin_trust.enforcement_is_off());
10915
10916        // A `plugin_trust` object present in JSON but with NO `enforcement`
10917        // key at all (e.g. a config.json written before this field existed)
10918        // must ALSO deserialize to Strict, not fail or silently do something
10919        // else — additive/back-compat, matching `trusted_hosts`/
10920        // `trusted_keys`'s own `#[serde(default = ...)]` behavior.
10921        let json = serde_json::json!({
10922            "trusted_hosts": ["example.com"],
10923            "trusted_keys": [],
10924        });
10925        let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10926        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10927    }
10928
10929    #[test]
10930    fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10931        for raw in ["off", "OFF", "Off", " off "] {
10932            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10933                "enforcement": raw,
10934            }))
10935            .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10936            assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10937            assert!(trust.enforcement_is_off());
10938        }
10939        for raw in ["strict", "STRICT", " Strict "] {
10940            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10941                "enforcement": raw,
10942            }))
10943            .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10944            assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10945        }
10946
10947        let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10948            "enforcement": "nonsense",
10949        }))
10950        .expect_err("an unrecognized string must be rejected, not silently default");
10951        assert!(err.to_string().contains("nonsense"));
10952    }
10953
10954    #[test]
10955    fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10956        // A hand-edited config.json using a plain bool reads naturally: is
10957        // enforcement ON (`true`) or OFF (`false`)?
10958        let trust: PluginTrustConfig =
10959            serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10960        assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10961
10962        let trust: PluginTrustConfig =
10963            serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10964        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10965    }
10966
10967    #[test]
10968    fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10969        // Regardless of which accepted input form produced it, the
10970        // in-memory value always serializes back out as the canonical
10971        // string — this is what the dot-path `config set` setter's
10972        // round-trip check relies on (see `dot_path.rs`'s module docs).
10973        let trust = PluginTrustConfig {
10974            enforcement: PluginTrustEnforcement::Off,
10975            ..PluginTrustConfig::default()
10976        };
10977        let json = serde_json::to_value(&trust).unwrap();
10978        assert_eq!(json["enforcement"], "off");
10979
10980        let trust = PluginTrustConfig {
10981            enforcement: PluginTrustEnforcement::Strict,
10982            ..PluginTrustConfig::default()
10983        };
10984        let json = serde_json::to_value(&trust).unwrap();
10985        assert_eq!(json["enforcement"], "strict");
10986    }
10987
10988    #[test]
10989    fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
10990        // `normalize_plugin_trust_settings` only touches `trusted_hosts` —
10991        // confirm it's a true no-op on `enforcement` either way.
10992        let mut config = Config::default();
10993        config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
10994        config.normalize_plugin_trust_settings();
10995        assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
10996    }
10997
10998    #[test]
10999    fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
11000        // Confirms the dot-path `bamboo config set plugin_trust.enforcement
11001        // off` path actually works end to end through
11002        // `crate::dot_path::apply_dot_path_set` (the generic JSON-patch
11003        // setter), not just direct field assignment.
11004        let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
11005            "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
11006        )));
11007        assert_eq!(
11008            config.plugin_trust.enforcement,
11009            PluginTrustEnforcement::Strict
11010        );
11011
11012        let outcome = crate::dot_path::apply_dot_path_set(
11013            &config,
11014            "plugin_trust.enforcement",
11015            crate::dot_path::parse_cli_value("off"),
11016        )
11017        .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
11018        assert_eq!(
11019            outcome.config.plugin_trust.enforcement,
11020            PluginTrustEnforcement::Off
11021        );
11022
11023        // And back to strict.
11024        let outcome = crate::dot_path::apply_dot_path_set(
11025            &outcome.config,
11026            "plugin_trust.enforcement",
11027            crate::dot_path::parse_cli_value("strict"),
11028        )
11029        .expect("setting it back to strict should also round-trip");
11030        assert_eq!(
11031            outcome.config.plugin_trust.enforcement,
11032            PluginTrustEnforcement::Strict
11033        );
11034    }
11035}
11036
11037#[cfg(test)]
11038mod final_evidence_feature_tests {
11039    use super::FeatureFlags;
11040
11041    #[test]
11042    fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
11043        let existing: FeatureFlags =
11044            serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
11045        assert!(!existing.final_evidence_check);
11046        let enabled: FeatureFlags =
11047            serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
11048        let restored: FeatureFlags =
11049            serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
11050        assert!(restored.final_evidence_check);
11051        assert!(!FeatureFlags::default().final_evidence_check);
11052    }
11053}