Skip to main content

bamboo_config/
config.rs

1//! Configuration management for Bamboo agent
2//!
3//! This module provides unified configuration types and loading logic for the entire
4//! Bamboo agent system. It supports multiple LLM providers, proxy settings,
5//! and JSON configuration format.
6//!
7//! # Configuration File
8//!
9//! Root configuration is stored in `config.json` under the unified data
10//! directory (defaults to `${HOME}/.bamboo/`). Memory, sub-agent, and legacy
11//! provider settings are independently persisted in `memory.json`,
12//! `subagents.json`, and `providers.json`. Environment variables can override
13//! file values.
14//!
15//! # Example `config.json`
16//!
17//! ```json
18//! {
19//!   "provider": "anthropic",
20//!   "server": {
21//!     "port": 9562,
22//!     "bind": "127.0.0.1"
23//!   }
24//! }
25//! ```
26//!
27//! # Example `providers.json`
28//!
29//! ```json
30//! {
31//!   "anthropic": {
32//!     "api_key_encrypted": "...",
33//!     "model": "claude-3-5-sonnet-20241022"
34//!   }
35//! }
36//! ```
37//!
38//! # Priority Order
39//!
40//! Configuration values are loaded in this order (later overrides earlier):
41//! 1. Code defaults (hardcoded default values)
42//! 2. Config file values (from `${HOME}/.bamboo/config.json`)
43//! 3. Independent sidecars (`memory.json`, `subagents.json`, `providers.json`)
44//! 4. Environment variables (e.g., `BAMBOO_PORT`)
45//! 5. CLI arguments (e.g., `--port 9000`)
46//!
47//! # Environment Variables
48//!
49//! - `BAMBOO_DATA_DIR`: Override data directory location
50//! - `BAMBOO_PORT`: Override server port
51//! - `BAMBOO_BIND`: Override server bind address
52//! - `BAMBOO_PROVIDER`: Override default provider
53//! - `BAMBOO_HEADLESS`: Enable headless authentication mode
54//! - `BAMBOO_OPENAI_API_KEY` / `BAMBOO_ANTHROPIC_API_KEY` / `BAMBOO_GEMINI_API_KEY`:
55//!   Supply a provider's API key from the environment (in-memory only, never
56//!   persisted) — for 12-factor / secret-manager / CI deploys without a
57//!   plaintext key in `providers.json`.
58
59use anyhow::{Context, Result};
60use bamboo_domain::patrol_model_limits::{
61    MAX_EVIDENCE_BYTES, MAX_EVIDENCE_REFS, MAX_INPUT_TOKENS, MAX_OUTPUT_TOKENS,
62    MAX_PROVIDER_INPUT_TOKENS,
63};
64use bamboo_domain::poison::PoisonRecover;
65use serde::{Deserialize, Serialize};
66use serde_json::Value;
67use std::collections::{BTreeMap, BTreeSet, HashMap};
68use std::path::{Path, PathBuf};
69use std::sync::{OnceLock, RwLock};
70
71use crate::keyword_masking::KeywordMaskingConfig;
72use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
73use bamboo_domain::normalize_tool_ref;
74use bamboo_domain::ReasoningEffort;
75
76/// Minimum accepted watchdog deadline. Zero would turn scheduling jitter into
77/// an immediate stream failure, so it is rejected at the configuration boundary.
78pub const MIN_STREAM_TIMEOUT_SECS: u64 = 1;
79/// Maximum accepted watchdog deadline. This keeps a typo from disabling hung
80/// stream detection for days while still allowing operators to accommodate
81/// exceptionally slow reasoning models.
82pub const MAX_STREAM_TIMEOUT_SECS: u64 = 86_400;
83
84fn default_transport_idle_timeout_secs() -> u64 {
85    120
86}
87
88fn default_first_semantic_timeout_secs() -> u64 {
89    600
90}
91
92fn default_semantic_idle_timeout_secs() -> u64 {
93    600
94}
95
96fn deserialize_stream_timeout_secs<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error>
97where
98    D: serde::Deserializer<'de>,
99{
100    use serde::de::Error as _;
101
102    let value = u64::deserialize(deserializer)?;
103    if (MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
104        Ok(value)
105    } else {
106        Err(D::Error::custom(format!(
107            "stream timeout must be between {MIN_STREAM_TIMEOUT_SECS} and \
108             {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
109        )))
110    }
111}
112
113/// LLM stream watchdog policy shared by the main response path and auxiliary
114/// silent consumers.
115///
116/// Transport and semantic progress are deliberately separate. Valid SSE
117/// lifecycle/keepalive frames refresh `transport_idle_timeout_secs` without
118/// hiding a model that never produces semantic output. The first semantic
119/// deadline starts at request dispatch; after the first token/reasoning/tool
120/// delta, `semantic_idle_timeout_secs` becomes the semantic deadline.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(default)]
123pub struct StreamTimeoutConfig {
124    /// Maximum gap between valid transport frames, including SSE keepalives.
125    #[serde(
126        default = "default_transport_idle_timeout_secs",
127        deserialize_with = "deserialize_stream_timeout_secs"
128    )]
129    pub transport_idle_timeout_secs: u64,
130    /// Maximum time from request dispatch to the first semantic chunk.
131    #[serde(
132        default = "default_first_semantic_timeout_secs",
133        deserialize_with = "deserialize_stream_timeout_secs"
134    )]
135    pub first_semantic_timeout_secs: u64,
136    /// Maximum gap between semantic chunks after semantic output has started.
137    #[serde(
138        default = "default_semantic_idle_timeout_secs",
139        deserialize_with = "deserialize_stream_timeout_secs"
140    )]
141    pub semantic_idle_timeout_secs: u64,
142}
143
144impl Default for StreamTimeoutConfig {
145    fn default() -> Self {
146        Self {
147            transport_idle_timeout_secs: default_transport_idle_timeout_secs(),
148            first_semantic_timeout_secs: default_first_semantic_timeout_secs(),
149            semantic_idle_timeout_secs: default_semantic_idle_timeout_secs(),
150        }
151    }
152}
153
154impl StreamTimeoutConfig {
155    /// Validate values created programmatically (serde performs the same check
156    /// while loading `config.json`).
157    pub fn validate(&self) -> std::result::Result<(), String> {
158        for (name, value) in [
159            (
160                "transport_idle_timeout_secs",
161                self.transport_idle_timeout_secs,
162            ),
163            (
164                "first_semantic_timeout_secs",
165                self.first_semantic_timeout_secs,
166            ),
167            (
168                "semantic_idle_timeout_secs",
169                self.semantic_idle_timeout_secs,
170            ),
171        ] {
172            if !(MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
173                return Err(format!(
174                    "{name} must be between {MIN_STREAM_TIMEOUT_SECS} and \
175                     {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
176                ));
177            }
178        }
179        Ok(())
180    }
181}
182
183/// Bounds for the initial-request retry policy. The attempt ceiling keeps a
184/// configuration write from widening the HTTP retry loop beyond what the
185/// request budget can pay for.
186pub const MIN_LLM_RETRY_ATTEMPTS: u32 = 1;
187pub const MAX_LLM_RETRY_ATTEMPTS: u32 = 12;
188pub const MIN_LLM_RETRY_DELAY_MS: u64 = 1;
189pub const MAX_LLM_RETRY_DELAY_MS: u64 = 300_000;
190pub const MIN_LLM_RETRY_ATTEMPT_TIMEOUT_MS: u64 = 1_000;
191pub const MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS: u64 = 600_000;
192pub const MIN_LLM_RETRY_TOTAL_BUDGET_MS: u64 = 1_000;
193pub const MAX_LLM_RETRY_TOTAL_BUDGET_MS: u64 = 3_600_000;
194
195fn default_llm_retry_attempts() -> u32 {
196    // Eight attempts absorb a relay blip or a queue spike instead of losing the
197    // turn. An attempt is only re-sent after the previous one failed, so this
198    // raises tolerance without raising the successful-path cost.
199    8
200}
201
202fn default_llm_retry_attempt_timeout_ms() -> u64 {
203    180_000
204}
205
206fn default_llm_retry_total_budget_ms() -> u64 {
207    900_000
208}
209
210fn default_llm_retry_base_delay_ms() -> u64 {
211    500
212}
213
214fn default_llm_retry_max_delay_ms() -> u64 {
215    30_000
216}
217
218/// Initial-request retry policy, operator-tunable from the settings page.
219///
220/// Only establishment of the initial HTTP response is retried; a successful
221/// stream is never replayed. `attempt_timeout_ms` is a *discard* deadline: on
222/// expiry the whole request body is dropped and sent again, so it must clear
223/// real first-byte latency rather than the median.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(default)]
226pub struct LlmRetryConfig {
227    /// Total attempts including the first. `1` disables retrying.
228    #[serde(default = "default_llm_retry_attempts")]
229    pub max_attempts: u32,
230    /// Deadline for response headers on one attempt.
231    #[serde(default = "default_llm_retry_attempt_timeout_ms")]
232    pub attempt_timeout_ms: u64,
233    /// Absolute budget shared by every attempt of one initial request.
234    #[serde(default = "default_llm_retry_total_budget_ms")]
235    pub total_budget_ms: u64,
236    /// Base exponential backoff step.
237    #[serde(default = "default_llm_retry_base_delay_ms")]
238    pub base_delay_ms: u64,
239    /// Upper bound on any single computed backoff sleep.
240    #[serde(default = "default_llm_retry_max_delay_ms")]
241    pub max_delay_ms: u64,
242}
243
244impl Default for LlmRetryConfig {
245    fn default() -> Self {
246        Self {
247            max_attempts: default_llm_retry_attempts(),
248            attempt_timeout_ms: default_llm_retry_attempt_timeout_ms(),
249            total_budget_ms: default_llm_retry_total_budget_ms(),
250            base_delay_ms: default_llm_retry_base_delay_ms(),
251            max_delay_ms: default_llm_retry_max_delay_ms(),
252        }
253    }
254}
255
256impl LlmRetryConfig {
257    /// Reject an out-of-range policy at the API boundary instead of letting it
258    /// widen the retry loop silently.
259    pub fn validate(&self) -> std::result::Result<(), String> {
260        let checks = [
261            (
262                "max_attempts",
263                u64::from(self.max_attempts),
264                u64::from(MIN_LLM_RETRY_ATTEMPTS),
265                u64::from(MAX_LLM_RETRY_ATTEMPTS),
266            ),
267            (
268                "attempt_timeout_ms",
269                self.attempt_timeout_ms,
270                MIN_LLM_RETRY_ATTEMPT_TIMEOUT_MS,
271                MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS,
272            ),
273            (
274                "total_budget_ms",
275                self.total_budget_ms,
276                MIN_LLM_RETRY_TOTAL_BUDGET_MS,
277                MAX_LLM_RETRY_TOTAL_BUDGET_MS,
278            ),
279            (
280                "base_delay_ms",
281                self.base_delay_ms,
282                MIN_LLM_RETRY_DELAY_MS,
283                MAX_LLM_RETRY_DELAY_MS,
284            ),
285            (
286                "max_delay_ms",
287                self.max_delay_ms,
288                MIN_LLM_RETRY_DELAY_MS,
289                MAX_LLM_RETRY_DELAY_MS,
290            ),
291        ];
292        for (name, value, min, max) in checks {
293            if !(min..=max).contains(&value) {
294                return Err(format!(
295                    "{name} must be between {min} and {max}, got {value}"
296                ));
297            }
298        }
299        if self.base_delay_ms > self.max_delay_ms {
300            return Err(format!(
301                "base_delay_ms ({}) must not exceed max_delay_ms ({})",
302                self.base_delay_ms, self.max_delay_ms
303            ));
304        }
305        Ok(())
306    }
307}
308
309/// A user-managed environment variable that is injected into Bash tool processes.
310///
311/// Secret entries are stored by reference in the isolated credential store:
312/// `value` is runtime-only for those entries, while `credential_ref` and
313/// `configured` are the only secret metadata persisted in ordinary config.
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
315pub struct EnvVarEntry {
316    /// Variable name (must match `^[A-Za-z_][A-Za-z0-9_]*$`).
317    pub name: String,
318    /// Plaintext value – populated in memory after hydration.
319    /// For `secret=true` entries this field is empty on disk.
320    #[serde(default, skip_serializing_if = "String::is_empty")]
321    pub value: String,
322    /// Whether this variable contains sensitive data (token, password, etc.).
323    #[serde(default)]
324    pub secret: bool,
325    /// Legacy inline ciphertext, accepted on read only so startup migration can
326    /// extract it. New serializers never emit this field.
327    #[serde(default, skip_serializing)]
328    pub value_encrypted: Option<String>,
329    /// Stable isolated-store reference for secret entries.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub credential_ref: Option<crate::CredentialRef>,
332    /// Durable status metadata; never inferred from a public mask.
333    #[serde(default)]
334    pub configured: bool,
335    /// Optional human-readable description.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub description: Option<String>,
338}
339
340/// Default work area configuration.
341///
342/// Allows Bamboo to operate without an explicit initial workspace while still
343/// providing a stable fallback directory for relative-path tool execution.
344#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
345pub struct DefaultWorkAreaConfig {
346    /// Optional default filesystem path used when a session has no active workspace.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub path: Option<String>,
349}
350
351/// User-selected locations for new user worktrees. Git remains the registry.
352#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
353pub struct WorktreeSettings {
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub default_root: Option<String>,
356    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
357    pub project_roots: BTreeMap<String, String>,
358}
359
360impl WorktreeSettings {
361    pub fn is_empty(&self) -> bool {
362        self.default_root.is_none() && self.project_roots.is_empty()
363    }
364}
365
366/// Access control configuration for password-based UI/API gating.
367#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
368pub struct AccessControlConfig {
369    /// Whether password protection is enabled.
370    #[serde(default)]
371    pub password_enabled: bool,
372    /// A malformed legacy verifier or device record was isolated into the
373    /// encrypted recovery store and needs an explicit user repair. Runtime
374    /// authorization treats this as fail-closed even when no usable verifier
375    /// can be hydrated.
376    #[serde(default)]
377    pub repair_required: bool,
378    /// Runtime-only password verifier hash. Legacy documents may still
379    /// deserialize it for migration, but ordinary section serialization never
380    /// writes verifier material.
381    #[serde(default, skip_serializing)]
382    pub password_hash: Option<String>,
383    /// Runtime-only password verifier salt.
384    #[serde(default, skip_serializing)]
385    pub password_salt: Option<String>,
386    /// Stable reference to the encrypted verifier record.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub password_credential_ref: Option<crate::CredentialRef>,
389    /// Durable configured metadata for redacted clients/runtime readiness.
390    #[serde(default)]
391    pub password_configured: bool,
392    /// Last update timestamp for auditing / debugging.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub updated_at: Option<String>,
395    /// v2 (#181): issued per-device tokens. Empty = root-password-only mode
396    /// (back-compat with old instances). Each entry stores only the token hash;
397    /// the plaintext token is returned to the client once at pairing time.
398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
399    pub devices: Vec<DeviceCredential>,
400}
401
402/// A single paired device's credential (v2-P2 per-device token, #181).
403///
404/// The server stores only `token_hash` (never the plaintext token). The hash is
405/// computed with the SAME construction as the access password — `SHA-256(salt ||
406/// token)` — so no new crypto dependency is introduced (`docs/api-v2-transport.md`
407/// §4.2).
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
409pub struct DeviceCredential {
410    /// Server-generated stable id: `bamboo_<12 hex>`.
411    pub device_id: String,
412    /// Human-readable label, e.g. "iPhone 15".
413    pub label: String,
414    /// Runtime-only `SHA-256(hex_decode(token_salt) || token)`, hex-encoded.
415    #[serde(default, skip_serializing)]
416    pub token_hash: String,
417    /// Runtime-only per-device salt (hex-encoded).
418    #[serde(default, skip_serializing)]
419    pub token_salt: String,
420    /// Stable reference to the encrypted device-token verifier record.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub token_credential_ref: Option<crate::CredentialRef>,
423    /// Durable configured metadata for runtime readiness.
424    #[serde(default)]
425    pub token_configured: bool,
426    /// RFC3339 creation timestamp.
427    pub created_at: String,
428    /// RFC3339 last-used timestamp (deferred stamping; see PR).
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub last_used_at: Option<String>,
431    /// Whether this device's token has been revoked. A revoked token is rejected
432    /// at the handshake/middleware immediately.
433    #[serde(default)]
434    pub revoked: bool,
435}
436
437/// Memory and background summarization configuration.
438// No `Eq`: `dedup_gardener_min_score` is an f64 (PartialEq only).
439#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
440pub struct MemoryConfig {
441    /// Optional dedicated model for memory/session summarization and reflection.
442    /// Falls back to the provider fast model when unset.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub background_model: Option<String>,
445    /// Desired conversation-summary size as a fraction of the raw source tokens
446    /// represented by that summary. The default keeps roughly 20% of source
447    /// content as durable working memory.
448    #[serde(default = "default_summary_target_ratio")]
449    pub summary_target_ratio: f64,
450    /// Maximum fraction of the summarization model context window consumed by a
451    /// fully rendered map/reduce request, including reserved output and safety
452    /// margin.
453    #[serde(default = "default_summary_safe_window_percent")]
454    pub summary_safe_window_percent: u8,
455    /// Whether lightweight automatic Dream-style consolidation should run in the
456    /// background. Default ON (memory redesign L4): each tick no-ops when there is
457    /// no background model configured or no new candidate sessions, so it is free
458    /// until there is real work + a model. Set false to opt out.
459    #[serde(default = "default_true_auto_dream_enabled")]
460    pub auto_dream_enabled: bool,
461    /// Seconds between background auto-Dream ticks (default 30 minutes).
462    /// Each tick still no-ops when there are no new candidate sessions, so raising
463    /// this only lowers how often an active user triggers a real consolidation.
464    #[serde(default = "default_auto_dream_interval_secs")]
465    pub auto_dream_interval_secs: u64,
466    /// Whether project durable-memory index injection is enabled for the main prompt.
467    #[serde(
468        default = "default_true_memory_project_prompt_injection",
469        alias = "memory_project_prompt_injection"
470    )]
471    pub project_prompt_injection: bool,
472    /// Whether automatic relevant durable-memory recall is enabled for the main prompt.
473    #[serde(
474        default = "default_true_memory_relevant_recall",
475        alias = "memory_relevant_recall"
476    )]
477    pub relevant_recall: bool,
478    /// Whether relevant durable-memory recall should rerank lexical shortlist candidates
479    /// using the configured memory/background model.
480    #[serde(default, alias = "memory_relevant_recall_rerank")]
481    pub relevant_recall_rerank: bool,
482    /// Whether Dream prompt injection should prefer project Dream and only use global Dream as fallback.
483    #[serde(
484        default = "default_true_memory_project_first_dream",
485        alias = "memory_project_first_dream"
486    )]
487    pub project_first_dream: bool,
488    /// Whether the ledger agenda (overdue/upcoming prospective records — todos,
489    /// events, reminders) is injected into the main prompt. Free when the
490    /// ledger is empty: the section is simply omitted.
491    #[serde(
492        default = "default_true_memory_ledger_agenda",
493        alias = "memory_ledger_agenda_injection"
494    )]
495    pub ledger_agenda_injection: bool,
496    /// Whether the background ledger gardener runs (expires past events/reminders,
497    /// reconciles record↔schedule drift, distills completed records into durable
498    /// memory). Expiry and reconciliation are deterministic and free; only
499    /// distillation uses the background model, and it no-ops without one.
500    #[serde(default = "default_true_ledger_gardener_enabled")]
501    pub ledger_gardener_enabled: bool,
502    /// Seconds between ledger gardener runs (default 6 hours).
503    #[serde(default = "default_ledger_gardener_interval_secs")]
504    pub ledger_gardener_interval_secs: u64,
505    /// Whether the ledger gardener's distillation pass (completed records →
506    /// durable memories via the background model) is enabled.
507    #[serde(default = "default_true_ledger_distillation_enabled")]
508    pub ledger_distillation_enabled: bool,
509    /// DEPRECATED (memory redesign L3): the "Refine" Dream mode — rewriting the
510    /// notebook from its own prior prose — was retired because a self-referential
511    /// narrative rewrite drifts from durable truth and silently over-merges. The
512    /// notebook is now always a grounded VIEW of the durable memory index (Rebuild)
513    /// or a session bootstrap (Incremental). This field is IGNORED; it is retained
514    /// only so existing config files that set it still deserialize.
515    #[serde(default, alias = "memory_dream_refine_mode")]
516    pub dream_refine_mode: bool,
517    /// Whether the background "gardener" may use the LLM to split/merge "blob" memories.
518    /// Default ON (memory redesign L4). The deterministic blob prefilter is cheap
519    /// and each run is bounded by `gardener_max_splits_per_run`; a run that finds
520    /// nothing, or finds work but has no background model, spends no tokens. Set
521    /// false to opt out.
522    #[serde(
523        default = "default_true_gardener_enabled",
524        alias = "memory_gardener_enabled"
525    )]
526    pub gardener_enabled: bool,
527    /// Seconds between gardener time-triggered runs (default daily). A run may also
528    /// fire early when the library grows — see `gardener_volume_trigger`.
529    #[serde(default = "default_gardener_interval_secs")]
530    pub gardener_interval_secs: u64,
531    /// Run the gardener maintenance pass early (before the next time tick) once this
532    /// many new durable memories have accumulated since the last run, so pileup is
533    /// bounded by growth, not only by the clock (memory redesign L4). 0 disables the
534    /// volume trigger (time-only). Per-run caps still bound the work done.
535    #[serde(default = "default_gardener_volume_trigger")]
536    pub gardener_volume_trigger: usize,
537    /// Hard cap on LLM-backed splits per gardener run (cost ceiling per run).
538    #[serde(default = "default_gardener_max_splits_per_run")]
539    pub gardener_max_splits_per_run: usize,
540    /// Minimum `---` accretions for a memory to be a gardener split candidate.
541    #[serde(default = "default_gardener_min_sections")]
542    pub gardener_min_sections: usize,
543    /// Whether the background dedup gardener may use the LLM to consolidate
544    /// near-duplicate memories. Default ON (memory redesign L4); bounded by
545    /// `dedup_gardener_max_merges_per_run` and no-ops without a model. Set false to
546    /// opt out.
547    #[serde(
548        default = "default_true_dedup_gardener_enabled",
549        alias = "memory_dedup_gardener_enabled"
550    )]
551    pub dedup_gardener_enabled: bool,
552    /// Minimum content-keyword Jaccard (0.0–1.0) for two active memories to be
553    /// flagged as dedup candidates by the deterministic prefilter.
554    #[serde(default = "default_dedup_gardener_min_score")]
555    pub dedup_gardener_min_score: f64,
556    /// Hard cap on LLM-backed consolidations per dedup gardener run (cost ceiling).
557    #[serde(default = "default_dedup_gardener_max_merges_per_run")]
558    pub dedup_gardener_max_merges_per_run: usize,
559    /// Max RECALLABLE (Active/Stale) memories per scope before the capacity gardener
560    /// archives the lowest-value overflow OUT of the recall index (memory redesign
561    /// L5 — archive, never delete; reversible). 0 = unbounded (feature OFF, the
562    /// default): consequential enough to be opt-in, since L4's dedup already curbs
563    /// most growth. `Reference`/`User`/`Feedback` memories are always exempt — so
564    /// the effective floor is the count of exempt Active memories in a scope; set
565    /// this comfortably above that (a capacity below it is a no-op, not a purge).
566    #[serde(default)]
567    pub memory_active_capacity: usize,
568    /// Hard cap on how many memories the capacity gardener archives per run, so a
569    /// large overflow drains gradually instead of in one burst.
570    #[serde(default = "default_capacity_max_archivals_per_run")]
571    pub capacity_max_archivals_per_run: usize,
572    /// Whether the background freshness gardener may conservatively demote Active
573    /// day/week-granularity memories to Stale once they cross their documented
574    /// staleness window (issue #61 phase 2; applied by Jiandu's
575    /// `MemoryStore::expire_stale_granularity`). Default ON,
576    /// matching the other gardener passes: deterministic (no LLM, no cost), and
577    /// non-destructive — it only ever moves Active → Stale, never archives or
578    /// deletes. Set false to opt out.
579    #[serde(default = "default_true_granularity_freshness_gardener_enabled")]
580    pub granularity_freshness_gardener_enabled: bool,
581}
582
583impl Default for MemoryConfig {
584    fn default() -> Self {
585        Self {
586            background_model: None,
587            summary_target_ratio: default_summary_target_ratio(),
588            summary_safe_window_percent: default_summary_safe_window_percent(),
589            auto_dream_enabled: default_true_auto_dream_enabled(),
590            auto_dream_interval_secs: default_auto_dream_interval_secs(),
591            project_prompt_injection: default_true_memory_project_prompt_injection(),
592            relevant_recall: default_true_memory_relevant_recall(),
593            relevant_recall_rerank: false,
594            project_first_dream: default_true_memory_project_first_dream(),
595            ledger_agenda_injection: default_true_memory_ledger_agenda(),
596            ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
597            ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
598            ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
599            dream_refine_mode: false,
600            gardener_enabled: default_true_gardener_enabled(),
601            gardener_interval_secs: default_gardener_interval_secs(),
602            gardener_volume_trigger: default_gardener_volume_trigger(),
603            gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
604            gardener_min_sections: default_gardener_min_sections(),
605            dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
606            dedup_gardener_min_score: default_dedup_gardener_min_score(),
607            dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
608            memory_active_capacity: 0,
609            capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
610            granularity_freshness_gardener_enabled:
611                default_true_granularity_freshness_gardener_enabled(),
612        }
613    }
614}
615
616fn default_summary_target_ratio() -> f64 {
617    0.20
618}
619
620fn default_summary_safe_window_percent() -> u8 {
621    80
622}
623
624fn default_true_granularity_freshness_gardener_enabled() -> bool {
625    true
626}
627
628fn default_capacity_max_archivals_per_run() -> usize {
629    50
630}
631
632fn default_true_auto_dream_enabled() -> bool {
633    true
634}
635
636fn default_true_gardener_enabled() -> bool {
637    true
638}
639
640fn default_true_dedup_gardener_enabled() -> bool {
641    true
642}
643
644fn default_true_memory_ledger_agenda() -> bool {
645    true
646}
647
648fn default_true_ledger_gardener_enabled() -> bool {
649    true
650}
651
652fn default_ledger_gardener_interval_secs() -> u64 {
653    21_600
654}
655
656fn default_true_ledger_distillation_enabled() -> bool {
657    true
658}
659
660/// Fire the gardener maintenance pass early once ~this many new memories accumulate
661/// since the last run. Conservative: large enough to avoid thrashing on a few
662/// writes, small enough to bound pileup well under a full (daily) interval.
663fn default_gardener_volume_trigger() -> usize {
664    25
665}
666
667fn default_gardener_interval_secs() -> u64 {
668    86_400
669}
670
671fn default_auto_dream_interval_secs() -> u64 {
672    60 * 30
673}
674
675fn default_gardener_max_splits_per_run() -> usize {
676    8
677}
678
679fn default_gardener_min_sections() -> usize {
680    5
681}
682
683fn default_dedup_gardener_min_score() -> f64 {
684    0.6
685}
686
687fn default_dedup_gardener_max_merges_per_run() -> usize {
688    8
689}
690
691fn default_true_memory_project_prompt_injection() -> bool {
692    true
693}
694
695fn default_true_memory_relevant_recall() -> bool {
696    true
697}
698
699fn default_true_memory_project_first_dream() -> bool {
700    true
701}
702
703/// Per-run resource guardrails (issue #221): a cost/resource ceiling applied
704/// across an entire `AgentRuntime::execute()` call (i.e. one user turn's worth
705/// of internal rounds — the same "run" granularity the round cap uses).
706///
707/// Every field is `None` by default (unlimited), matching the rest of this
708/// config's opt-in-only posture. A per-request `ExecuteRequest::run_budget`
709/// override (HTTP `POST /execute` body) may only TIGHTEN this config-level
710/// default, never loosen it — per field, the effective limit is the minimum
711/// of the two (see [`RunBudgetConfig::merged_with_override`] and
712/// `bamboo_engine::runtime::runtime::AgentRuntime::execute`).
713///
714/// Exceeding any configured limit gracefully stops the run (mirrors the
715/// round-cap exhaustion path: one final summary turn, then a terminal stop
716/// with `runtime.completion_reason` = `"budget_exceeded"` for resource fields
717/// and `"max_rounds_reached"` for the round cap, plus a structured
718/// `AgentEvent::BudgetExceeded`) rather than erroring out — the run stays
719/// resumable.
720#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
721pub struct RunBudgetConfig {
722    /// Maximum total tokens (prompt + completion, actual provider-reported
723    /// usage summed across the run's rounds) before the run is stopped.
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub max_total_tokens: Option<u64>,
726    /// Maximum total tool calls (across every round of the run, not just one
727    /// round — see `max_tool_calls_per_round` for the existing per-round cap)
728    /// before the run is stopped.
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub max_tool_calls: Option<u32>,
731    /// Maximum total `SubAgent` create calls (across the whole run) before the
732    /// run is stopped. Distinct from `subagents.max_concurrent`, which caps how
733    /// many child actor processes run AT ONCE, not how many a single run may
734    /// spawn in total over its lifetime.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub max_subagents: Option<u32>,
737    /// Maximum internal rounds for a single run before the run is gracefully
738    /// stopped. `None` (the default) means unlimited: the loop runs until the
739    /// model stops calling tools, another guardrail trips, or the run is
740    /// cancelled. Setting a cap keeps the issue #29 exhaustion contract
741    /// (`runtime.completion_reason = "max_rounds_reached"`, a visible
742    /// notification, and exactly one final summary turn).
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub max_rounds: Option<u32>,
745}
746
747/// Tighten-only per-field merge: the effective limit is the MINIMUM of the
748/// config default and the request override, with `None` = unlimited.
749fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
750    match (config_default, request) {
751        (Some(a), Some(b)) => Some(a.min(b)),
752        (Some(a), None) => Some(a),
753        (None, Some(b)) => Some(b),
754        (None, None) => None,
755    }
756}
757
758impl RunBudgetConfig {
759    /// Merge a per-request override with this config-level default,
760    /// **tighten-only** (issue #221, PR #539 review): per field, the
761    /// effective limit is the MINIMUM of the two (`None` = unlimited), so a
762    /// `POST /execute` caller can lower a budget below the operator's
763    /// configured ceiling but can never raise or remove it.
764    ///
765    /// Rationale: `run_budget` is a defensive cost circuit-breaker, and the
766    /// server's other guardrails (`max_rounds`, per-round tool caps, …) are
767    /// not client-overridable at all. A client-loosenable ceiling would be no
768    /// ceiling: any caller of `/execute` could send
769    /// `max_total_tokens: u64::MAX` and erase the operator's cap. Overrides
770    /// looser than the config default are silently clamped to it rather than
771    /// rejected — the caller still gets the strictest applicable budget,
772    /// which is always a safe interpretation of their request.
773    pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
774        let Some(over) = request_override else {
775            return *self;
776        };
777        Self {
778            max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
779            max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
780            max_subagents: min_limit(self.max_subagents, over.max_subagents),
781            max_rounds: min_limit(self.max_rounds, over.max_rounds),
782        }
783    }
784}
785
786/// Authentication/provider posture for `executor = "codex"`.
787///
788/// `None` at the containing config field is interpreted as [`Self::Bamboo`],
789/// keeping old documents backward-compatible while making the safe,
790/// parent-routed mode the runtime default.
791#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
792#[serde(rename_all = "snake_case")]
793pub enum CodexAuthMode {
794    /// Use the invoking user's default `~/.codex` auth and configuration.
795    Inherit,
796    /// Isolated `CODEX_HOME`; `OPENAI_API_KEY` must be explicitly forwarded.
797    ApiKey,
798    /// Isolated `CODEX_HOME` with a custom provider and referenced credential.
799    Custom,
800    /// Isolated `CODEX_HOME`; route through this Bamboo server with a per-run token.
801    #[default]
802    Bamboo,
803}
804
805/// Codex 0.144+ only accepts the Responses wire protocol for custom providers.
806#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
807#[serde(rename_all = "snake_case")]
808pub enum CodexWireApi {
809    #[default]
810    Responses,
811}
812
813/// Codex transport used by `executor = "codex"`.
814#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
815#[serde(rename_all = "snake_case")]
816pub enum CodexMode {
817    /// One `codex exec --json` process per activation.
818    #[default]
819    Exec,
820    /// Long-lived `codex app-server` JSON-RPC session with approval relay.
821    AppServer,
822}
823
824/// OS sandbox selected for non-interactive `codex exec` children.
825#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
826#[serde(rename_all = "kebab-case")]
827pub enum CodexSandbox {
828    ReadOnly,
829    WorkspaceWrite,
830    DangerFullAccess,
831}
832
833/// Codex approval policy. Mode-specific validation rejects interactive policy
834/// in exec mode and non-interactive policy in app-server mode.
835#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(rename_all = "kebab-case")]
837pub enum CodexApprovalPolicy {
838    Never,
839    OnFailure,
840    OnRequest,
841}
842
843/// Default number of child levels below a Root (whose depth is zero).
844pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
845
846/// Sub-agent execution settings.
847///
848/// Sub-agents always run as independent **actor** processes — an isolated OS
849/// process with its own context (crash isolation, true parallelism, per-child
850/// resource limits). The historical in-process runtime was removed, so there is
851/// no longer a runtime toggle (a stray `"runtime"`/`"overrides"` key in an old
852/// config is ignored). The worker binary, its arguments, and the discovery
853/// directory are derived automatically (the current `bamboo` executable +
854/// `subagent-worker`); the expert fields below override them only when you run a
855/// custom worker.
856#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
857pub struct SubagentsConfig {
858    /// Maximum child depth below Root=0. Zero disables new child creation.
859    /// Unset preserves the historical four-level limit.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub max_spawn_depth: Option<u32>,
862    /// Maximum actor activations running at once; further spawns wait their
863    /// turn. Default: 200. Warm-idle process retention has a separate, smaller
864    /// bound.
865    #[serde(default, skip_serializing_if = "Option::is_none")]
866    pub max_concurrent: Option<usize>,
867    /// Expert: custom worker binary. Default: the current bamboo executable.
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    pub worker_bin: Option<String>,
870    /// Expert: arguments for the custom worker binary. Default for the
871    /// built-in worker: `["subagent-worker"]`.
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    pub worker_args: Option<Vec<String>>,
874    /// Expert: discovery fabric directory. Default: a per-user temp dir.
875    #[serde(default, skip_serializing_if = "Option::is_none")]
876    pub fabric_dir: Option<String>,
877    /// Expert: `"echo"` swaps in a dependency-free smoke executor (no LLM)
878    /// to verify the actor chain end-to-end; `"claude_code"` drives the
879    /// official Claude Code CLI; `"codex"` drives the selected Codex mode.
880    #[serde(default, skip_serializing_if = "Option::is_none")]
881    pub executor: Option<String>,
882    /// `executor = "claude_code"` only: override the `claude` executable.
883    /// `None` runs `claude` resolved from `PATH`.
884    #[serde(default, skip_serializing_if = "Option::is_none")]
885    pub claude_code_binary: Option<String>,
886    /// `executor = "claude_code"` only: `--model` override. `None` omits the
887    /// flag (CLI default).
888    #[serde(default, skip_serializing_if = "Option::is_none")]
889    pub claude_code_model: Option<String>,
890    /// `executor = "claude_code"` only: `--permission-mode` override. `None`
891    /// still passes an EXPLICIT `default` to the CLI (issue #443 — the
892    /// headless stream-json default is `auto`, which self-approves every
893    /// tool and never asks); it does not mean "omit the flag".
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub claude_code_permission_mode: Option<String>,
896    /// `executor = "claude_code"` only: `true` lets the child inherit the
897    /// invoking user's `~/.claude` MCP servers/skills/settings. `false`/unset
898    /// (the default) isolates it (`--strict-mcp-config` +
899    /// `--setting-sources project`).
900    #[serde(default, skip_serializing_if = "Option::is_none")]
901    pub claude_code_inherit_user_config: Option<bool>,
902    /// `executor = "claude_code"` only: extra env var NAMES forwarded
903    /// verbatim from this process's env to the child, on top of the fixed
904    /// HOME/PATH/SHELL/TERM/LANG/LC_*/TMPDIR/USER/LOGNAME allowlist.
905    /// Forwarding `ANTHROPIC_API_KEY` here is an explicit opt-in that flips
906    /// billing from the CLI's own subscription auth to the API key.
907    #[serde(default, skip_serializing_if = "Option::is_none")]
908    pub claude_code_forward_env: Option<Vec<String>>,
909    /// `executor = "codex"` only: override the `codex` executable. `None`
910    /// resolves `codex` from `PATH`.
911    #[serde(default, skip_serializing_if = "Option::is_none")]
912    pub codex_binary: Option<String>,
913    /// `executor = "codex"` only: `--model` override. `None` uses the CLI
914    /// default model.
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub codex_model: Option<String>,
917    /// `executor = "codex"` only: `exec` (default) or long-lived `app_server`.
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub codex_mode: Option<CodexMode>,
920    /// `executor = "codex"` only: authentication/provider mode. Unset defaults
921    /// to `bamboo`, which keeps provider credentials in the parent process.
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub codex_auth_mode: Option<CodexAuthMode>,
924    /// `codex_auth_mode = "custom"` only: absolute HTTP(S) provider base URL.
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub codex_base_url: Option<String>,
927    /// `codex_auth_mode = "custom"` only. Codex 0.144+ supports `responses`.
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub codex_wire_api: Option<CodexWireApi>,
930    /// `codex_auth_mode = "custom"` only: stable reference to an existing
931    /// provider credential. The plaintext key remains in the credential store
932    /// and is injected into the child process environment only.
933    #[serde(default, skip_serializing_if = "Option::is_none")]
934    pub codex_provider_key_ref: Option<crate::CredentialRef>,
935    /// Extra env var names copied into Codex after `env_clear()`. API-key mode
936    /// requires an explicit `OPENAI_API_KEY` entry; it is never implicit.
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub codex_forward_env: Option<Vec<String>>,
939    /// Explicit Codex sandbox override. Unset derives the sandbox from the
940    /// child permission profile and the parent session's bypass posture.
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    pub codex_sandbox: Option<CodexSandbox>,
943    /// Mode-specific approval policy. Exec resolves to a non-interactive safe
944    /// value; app-server requires `on-request` and routes it to the parent.
945    #[serde(default, skip_serializing_if = "Option::is_none")]
946    pub codex_approval_policy: Option<CodexApprovalPolicy>,
947    /// Permit network access from a workspace-write sandbox.
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub codex_network_access: Option<bool>,
950    /// Opt in to disabling the Codex OS sandbox, but only when the parent run
951    /// is itself in bypass mode. False/unset keeps bypass runs sandboxed.
952    #[serde(default, skip_serializing_if = "Option::is_none")]
953    pub codex_allow_danger_bypass: Option<bool>,
954    /// The active message-broker endpoint the `ask_agent` tool / sub-agent bus
955    /// dials. RUNTIME-ONLY (`#[serde(skip)]`): never read from nor written to
956    /// `config.json`. It is populated in memory each boot by `maybe_embed_broker`
957    /// — either from a user-managed external broker in `<data_dir>/broker.json`,
958    /// or from the freshly-embedded in-process broker (whose ephemeral loopback
959    /// port must NEVER be persisted, else a later boot dials a dead port).
960    #[serde(skip)]
961    pub broker: Option<BrokerClientConfig>,
962    /// Remote placements: pin specific sub-agent roles to resident workers
963    /// reached over `wss://` instead of a locally-spawned subprocess
964    /// (remote-actor-plan §3.4 / P1.5, #193). Empty (the default) keeps every
965    /// role on the local path — fully back-compatible: an old config with no
966    /// `remote_placements` key deserializes to an empty vec.
967    #[serde(default, skip_serializing_if = "Vec::is_empty")]
968    pub remote_placements: Vec<RemoteActorPlacement>,
969    /// Schedulable placements: route specific sub-agent roles to a LIVE worker
970    /// resolved from the agent registry at run time, instead of a locally-spawned
971    /// subprocess (remote-actor-plan §3.4 / P2b, #181). Unlike `remote_placements`
972    /// (a fixed endpoint), a schedulable placement names a logical `pool` and a
973    /// `registry_url`; the engine queries the registry for live workers in that
974    /// pool and picks one. Empty (the default) keeps every role on the local path
975    /// — fully back-compatible: an old config with no `schedulable_placements` key
976    /// deserializes to an empty vec.
977    ///
978    /// PRECEDENCE: if a role appears in BOTH `remote_placements` and
979    /// `schedulable_placements`, the fixed remote placement wins (it is resolved
980    /// first in `build_spec`).
981    #[serde(default, skip_serializing_if = "Vec::is_empty")]
982    pub schedulable_placements: Vec<SchedulablePlacement>,
983    /// Per-role allowlist scoping which host-bound MCP tools a sub-agent role
984    /// may see/call through the orchestrator's MCP proxy (issue #54;
985    /// `bamboo_broker::RoleToolAllowlist`). Read and enforced
986    /// ORCHESTRATOR-side when wiring `serve_mcp_proxy` — this is deliberately
987    /// NOT part of the worker-facing `McpProxyConfig` a deployed worker
988    /// receives, because a worker self-declaring its own allowlist would be
989    /// insecure (it could simply claim to be unrestricted). A role absent
990    /// from this list is unrestricted (sees/can call every proxiable tool),
991    /// so adding this policy never silently strips tools from an
992    /// already-deployed role you have not listed here. Empty (the default)
993    /// keeps every role unrestricted — fully backward compatible with
994    /// pre-#54 behavior.
995    ///
996    /// Role AND tool names are matched by exact string equality against the
997    /// worker-asserted `AgentRef.role` / the requested tool name — see
998    /// `RoleToolAllowlist`'s doc comment for the resulting self-asserted-role
999    /// caveat (this policy is adequate against a confused/hallucinating
1000    /// worker, not a malicious one that lies about its own role).
1001    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1002    pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
1003}
1004
1005impl SubagentsConfig {
1006    pub fn effective_max_spawn_depth(&self) -> u32 {
1007        self.max_spawn_depth.unwrap_or(DEFAULT_MAX_SPAWN_DEPTH)
1008    }
1009}
1010
1011/// One role's MCP proxy tool allowlist entry (issue #54). See
1012/// [`SubagentsConfig::mcp_role_allowlist`].
1013#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1014pub struct McpRoleAllowlistEntry {
1015    /// Sub-agent role this entry restricts — matches the worker-asserted
1016    /// `AgentRef.role` (itself the child session's `subagent_type` /
1017    /// `ChildIdentity.role`). There is no fixed registry of valid roles in
1018    /// this codebase (roles are free-form profile ids), so a typo here is
1019    /// NOT caught against a "known roles" list — only structurally (blank
1020    /// names are dropped, duplicates warn) at load time. Double-check this
1021    /// against the role string your profile/deploy config actually uses.
1022    pub role: String,
1023    /// Tool names this role may see in its manifest / call through the
1024    /// proxy, matched by exact string equality against the backend's
1025    /// registered tool name. An entry with an EMPTY list is an explicit
1026    /// lockout (no tools) for that role, distinct from the role being absent
1027    /// from this Vec entirely (unrestricted). Validated at load time against
1028    /// the orchestrator's live MCP tool set where available — an unknown
1029    /// name is still enforced (kept) but logged as a likely typo.
1030    #[serde(default)]
1031    pub tools: Vec<String>,
1032}
1033
1034/// Operator-owned hard constraints for a remote WorkerHost. The broker's
1035/// authenticated peer policy advertises capacity; it does not choose the
1036/// trust/data boundary for a Child. Missing requirements leave an explicitly
1037/// selected remote placement unavailable rather than inferring authority from
1038/// the worker's own capabilities.
1039#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1040#[serde(deny_unknown_fields)]
1041pub struct OperatorPlacementRequirements {
1042    pub trust_zone: String,
1043    #[serde(default, skip_serializing_if = "Option::is_none")]
1044    pub workspace_label: Option<String>,
1045    #[serde(default, skip_serializing_if = "Option::is_none")]
1046    pub network_zone: Option<String>,
1047    #[serde(default)]
1048    pub require_network_isolation: bool,
1049    /// Operator-declared tools the selected Host must support. The runner adds
1050    /// a native tool ceiling when one is bound to the Child.
1051    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
1052    pub required_tools: std::collections::BTreeSet<String>,
1053}
1054
1055/// The fixed parent identity used by a scoped broker pool. Every candidate
1056/// WorkerHost is separately selected by its authenticated observation.
1057#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1058#[serde(deny_unknown_fields)]
1059pub struct SchedulableBrokerParent {
1060    pub parent_mailbox: String,
1061    pub parent_role: String,
1062}
1063
1064/// Route a role to a capacity-managed WorkerHost pool through a scoped WSS
1065/// broker. The operator supplies the parent identity and hard constraints;
1066/// legacy entries remain readable but unavailable until migrated.
1067#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1068pub struct SchedulablePlacement {
1069    /// Sub-agent role this targets (matches the child session's
1070    /// `metadata["subagent_type"]`).
1071    pub role: String,
1072    /// Logical pool name — the registry `role` to query for live workers.
1073    pub pool: String,
1074    /// Scoped WSS broker endpoint. The old HTTP registry was retired.
1075    #[serde(default, skip_serializing_if = "String::is_empty")]
1076    pub registry_url: String,
1077    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
1078    /// `auth_ref`). Used for BOTH the registry query and the worker connect.
1079    /// `None` ⇒ query/connect without a bearer (trusted link only).
1080    #[serde(default, skip_serializing_if = "Option::is_none")]
1081    pub token_env: Option<String>,
1082    /// PEM file pinning the scoped broker certificate.
1083    #[serde(default, skip_serializing_if = "Option::is_none")]
1084    pub ca_cert_file: Option<String>,
1085    /// Required for authority-backed scheduling. `registry_url` is the scoped
1086    /// WSS broker URL for this route; legacy entries remain readable but cannot
1087    /// dispatch a Run without a trusted parent identity and requirements.
1088    #[serde(default, skip_serializing_if = "Option::is_none")]
1089    pub broker_parent: Option<SchedulableBrokerParent>,
1090    #[serde(default, skip_serializing_if = "Option::is_none")]
1091    pub placement_requirements: Option<OperatorPlacementRequirements>,
1092}
1093
1094/// Pins a single sub-agent role to a remote resident worker (remote-actor-plan
1095/// §3.4 / P1.5). A child whose `subagent_type` matches `role` is connected over
1096/// `wss://` to `endpoint` (Bearer-authenticated) instead of being spawned as a
1097/// local subprocess. No role match ⇒ that child stays on the local path.
1098///
1099/// The bearer token is NEVER stored here in the clear: `token_env` names the
1100/// environment variable that holds it (mirroring the A2A `auth_ref` pattern),
1101/// read once at runner-build time. A `token_env` that is set-but-unset at build
1102/// time leaves an explicit placement unavailable. A `broker_peer` selection
1103/// is required for execution; legacy direct-worker config remains readable.
1104#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1105pub struct RemoteActorPlacement {
1106    /// Sub-agent role this targets (matches the child session's
1107    /// `metadata["subagent_type"]`).
1108    pub role: String,
1109    /// Broker endpoint for a scoped peer route. Legacy worker endpoints remain
1110    /// readable but are unavailable until migrated to a broker route.
1111    pub endpoint: String,
1112    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
1113    /// `auth_ref`). `None` ⇒ connect without a bearer (trusted link only).
1114    #[serde(default, skip_serializing_if = "Option::is_none")]
1115    pub token_env: Option<String>,
1116    /// PEM file pinning a self-signed worker cert. `None` ⇒ default webpki roots
1117    /// (or plaintext `ws://`).
1118    #[serde(default, skip_serializing_if = "Option::is_none")]
1119    pub ca_cert_file: Option<String>,
1120    /// Explicit scoped broker route. Absence keeps the placement selected but
1121    /// unavailable, never a direct parent-to-worker connection.
1122    #[serde(default, skip_serializing_if = "Option::is_none")]
1123    pub broker_peer: Option<RemoteBrokerPeer>,
1124    #[serde(default, skip_serializing_if = "Option::is_none")]
1125    pub placement_requirements: Option<OperatorPlacementRequirements>,
1126}
1127
1128/// Operator-pinned transport identities; these do not grant Actor ownership.
1129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1130#[serde(deny_unknown_fields)]
1131pub struct RemoteBrokerPeer {
1132    pub parent_mailbox: String,
1133    pub worker_mailbox: String,
1134    pub parent_role: Option<String>,
1135    pub worker_role: Option<String>,
1136}
1137impl RemoteBrokerPeer {
1138    pub fn valid(&self) -> bool {
1139        fn identifier(s: &str, mailbox: bool) -> bool {
1140            !s.is_empty()
1141                && s.len() <= 256
1142                && s != "."
1143                && s != ".."
1144                && s.bytes()
1145                    .all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
1146                && (!mailbox || !s.bytes().any(|b| b.is_ascii_uppercase()))
1147        }
1148        identifier(&self.parent_mailbox, true)
1149            && identifier(&self.worker_mailbox, true)
1150            && self.parent_mailbox != self.worker_mailbox
1151            && self
1152                .parent_role
1153                .as_deref()
1154                .is_none_or(|s| identifier(s, false))
1155            && self
1156                .worker_role
1157                .as_deref()
1158                .is_some_and(|s| identifier(s, false))
1159    }
1160}
1161
1162/// How to reach the central sub-agent message broker (`bamboo broker serve`).
1163#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
1164pub struct BrokerClientConfig {
1165    /// Broker WebSocket endpoint, e.g. `ws://broker-host:9600`.
1166    pub endpoint: String,
1167    /// Bearer token presented in the broker handshake.
1168    ///
1169    /// Runtime-only plaintext hydrated from the isolated credential store.
1170    /// Legacy `broker.json` files may still deserialize this field so the
1171    /// manifest migration can extract it, but serializers never emit it.
1172    #[serde(default, skip_serializing)]
1173    pub token: String,
1174    /// Legacy inline ciphertext accepted only for credential migration.
1175    #[serde(default, skip_serializing)]
1176    pub token_encrypted: Option<String>,
1177    /// Stable reference to the external broker bearer token.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub credential_ref: Option<crate::CredentialRef>,
1180    /// Durable configured metadata. Runtime hydration still verifies that the
1181    /// referenced credential exists and decrypts successfully.
1182    #[serde(default)]
1183    pub configured: bool,
1184}
1185
1186impl std::fmt::Debug for BrokerClientConfig {
1187    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1188        formatter
1189            .debug_struct("BrokerClientConfig")
1190            .field("endpoint", &self.endpoint)
1191            .field("credential_ref", &self.credential_ref)
1192            .field("configured", &self.configured)
1193            .finish_non_exhaustive()
1194    }
1195}
1196
1197/// Native desktop (OS-notification) delivery channel.
1198#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1199pub struct DesktopChannelConfig {
1200    /// `None` = auto: on when Bamboo runs as a standalone `bamboo serve`
1201    /// process, off when spawned as a sidecar under `--parent-pid` (a native
1202    /// shell such as Bodhi owns notification UX in that mode — desktop
1203    /// notifications from both the sidecar and the shell would double-fire).
1204    /// `Some(_)` is an explicit user override of that default in either
1205    /// direction.
1206    #[serde(default, skip_serializing_if = "Option::is_none")]
1207    pub enabled: Option<bool>,
1208}
1209
1210/// [ntfy.sh](https://ntfy.sh) push notification channel (self-hostable).
1211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1212pub struct NtfyChannelConfig {
1213    #[serde(default)]
1214    pub enabled: bool,
1215    /// ntfy server base URL (public ntfy.sh or a self-hosted instance).
1216    #[serde(default = "default_ntfy_base_url")]
1217    pub base_url: String,
1218    /// Topic to publish to. Priority mapping from notification category is
1219    /// left to the delivery sink, not configured here.
1220    #[serde(default)]
1221    pub topic: String,
1222    /// Access token for a protected/self-hosted ntfy instance (public ntfy.sh
1223    /// topics need none).
1224    ///
1225    /// Secret plaintext hydrated in memory from the isolated credential store;
1226    /// never serialized in ordinary config.
1227    #[serde(default, skip_serializing)]
1228    pub token: Option<String>,
1229    /// Legacy encrypted ciphertext accepted only for migration.
1230    #[serde(default, skip_serializing)]
1231    pub token_encrypted: Option<String>,
1232    /// Stable isolated credential-store reference.
1233    #[serde(default, skip_serializing_if = "Option::is_none")]
1234    pub credential_ref: Option<crate::CredentialRef>,
1235    /// Whether the referenced credential is expected to exist.
1236    #[serde(default)]
1237    pub configured: bool,
1238}
1239
1240impl Default for NtfyChannelConfig {
1241    fn default() -> Self {
1242        Self {
1243            enabled: false,
1244            base_url: default_ntfy_base_url(),
1245            topic: String::new(),
1246            token: None,
1247            token_encrypted: None,
1248            credential_ref: None,
1249            configured: false,
1250        }
1251    }
1252}
1253
1254fn default_ntfy_base_url() -> String {
1255    "https://ntfy.sh".to_string()
1256}
1257
1258/// [Bark](https://github.com/Finb/Bark) iOS push notification channel.
1259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1260pub struct BarkChannelConfig {
1261    #[serde(default)]
1262    pub enabled: bool,
1263    /// Bark server base URL (public api.day.app or a self-hosted instance).
1264    #[serde(default = "default_bark_base_url")]
1265    pub base_url: String,
1266    /// Bark device key identifying the target iOS device.
1267    ///
1268    /// Secret plaintext hydrated in memory from the isolated credential store;
1269    /// never serialized in ordinary config.
1270    #[serde(default, skip_serializing)]
1271    pub device_key: Option<String>,
1272    /// Legacy encrypted ciphertext accepted only for migration.
1273    #[serde(default, skip_serializing)]
1274    pub device_key_encrypted: Option<String>,
1275    /// Stable isolated credential-store reference.
1276    #[serde(default, skip_serializing_if = "Option::is_none")]
1277    pub credential_ref: Option<crate::CredentialRef>,
1278    /// Whether the referenced credential is expected to exist.
1279    #[serde(default)]
1280    pub configured: bool,
1281}
1282
1283impl Default for BarkChannelConfig {
1284    fn default() -> Self {
1285        Self {
1286            enabled: false,
1287            base_url: default_bark_base_url(),
1288            device_key: None,
1289            device_key_encrypted: None,
1290            credential_ref: None,
1291            configured: false,
1292        }
1293    }
1294}
1295
1296fn default_bark_base_url() -> String {
1297    "https://api.day.app".to_string()
1298}
1299
1300/// Notification delivery channels: native desktop plus push-relay services.
1301///
1302/// Additive/back-compat: an absent `notifications` key in `config.json`
1303/// deserializes to the defaults (desktop auto, ntfy/bark disabled).
1304#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1305pub struct NotificationsConfig {
1306    #[serde(default)]
1307    pub desktop: DesktopChannelConfig,
1308    #[serde(default)]
1309    pub ntfy: NtfyChannelConfig,
1310    #[serde(default)]
1311    pub bark: BarkChannelConfig,
1312}
1313
1314/// One IM-platform bridge configured under `[[connect.platforms]]` —
1315/// bamboo-connect (issue #452 / epic #447): drives a bamboo session from an
1316/// external chat platform (Telegram first).
1317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1318pub struct ConnectPlatformConfig {
1319    /// Stable per-entry identifier (#496). Not part of the original schema —
1320    /// absent on legacy/hand-written entries and on a freshly-echoed new
1321    /// entry from a client. [`Config::save_to_dir`] assigns one (a random
1322    /// UUID) to any entry that lacks it as part of the normal save path
1323    /// (migration-on-write); load never mutates/rewrites the config to
1324    /// backfill it, per #493's never-overwrite-until-confirmed semantics.
1325    ///
1326    /// Used by [`crate::patch::preserve_masked_connect_secrets`] as the
1327    /// FIRST resolution strategy for a masked secret in a settings PATCH,
1328    /// ahead of the positional/`type`-based fallbacks (#490/#492) — an exact
1329    /// id match unambiguously identifies the same logical entry even when
1330    /// two entries share the same `platform_type` and have been reordered,
1331    /// which position+type alone cannot always disambiguate.
1332    #[serde(default, skip_serializing_if = "Option::is_none")]
1333    pub id: Option<String>,
1334    /// Optional first-class Project id assigned to newly-created sessions from
1335    /// this connector. Existing connector sessions keep their persisted
1336    /// membership when configuration changes.
1337    #[serde(default, skip_serializing_if = "Option::is_none")]
1338    pub project_id: Option<bamboo_domain::ProjectId>,
1339    /// Platform adapter selector, e.g. `"telegram"`. Unrecognized values are
1340    /// skipped (with a startup warning) rather than failing config load —
1341    /// forward-compatible with future adapters (Feishu/Slack).
1342    #[serde(rename = "type")]
1343    pub platform_type: String,
1344    /// Platform bot/API token.
1345    ///
1346    /// Secret: encrypted at rest in `token_encrypted`; this plaintext field is
1347    /// never serialized and is hydrated in memory on load (mirrors
1348    /// [`NtfyChannelConfig::token`] / [`BarkChannelConfig::device_key`]).
1349    #[serde(default, skip_serializing)]
1350    pub token: Option<String>,
1351    /// Encrypted ciphertext of `token` (the at-rest representation).
1352    #[serde(default, skip_serializing_if = "Option::is_none")]
1353    pub token_encrypted: Option<String>,
1354    /// Stable reference to the isolated token credential.
1355    #[serde(default, skip_serializing_if = "Option::is_none")]
1356    pub token_credential_ref: Option<crate::CredentialRef>,
1357    /// Durable metadata used by redacted clients without exposing a value.
1358    #[serde(default)]
1359    pub token_configured: bool,
1360    /// Platform app id (Feishu `app_id`). Not a secret — serialized normally.
1361    /// Unused by the Telegram adapter.
1362    #[serde(default, skip_serializing_if = "Option::is_none")]
1363    pub app_id: Option<String>,
1364    /// Platform app secret (Feishu `app_secret`).
1365    ///
1366    /// Secret: encrypted at rest in `app_secret_encrypted`; this plaintext
1367    /// field is never serialized and is hydrated in memory on load (mirrors
1368    /// `token` above).
1369    #[serde(default, skip_serializing)]
1370    pub app_secret: Option<String>,
1371    /// Encrypted ciphertext of `app_secret` (the at-rest representation).
1372    #[serde(default, skip_serializing_if = "Option::is_none")]
1373    pub app_secret_encrypted: Option<String>,
1374    /// Stable reference to the isolated app-secret credential.
1375    #[serde(default, skip_serializing_if = "Option::is_none")]
1376    pub app_secret_credential_ref: Option<crate::CredentialRef>,
1377    /// Durable metadata used by redacted clients without exposing a value.
1378    #[serde(default)]
1379    pub app_secret_configured: bool,
1380    /// Platform domain/base-URL selector (Feishu-only today). Not a secret —
1381    /// serialized normally. `None`/`"feishu"` -> open.feishu.cn, `"lark"` ->
1382    /// open.larksuite.com, an `https://` value -> a private-deployment base
1383    /// URL used verbatim. Validation happens in the server registration arm,
1384    /// not here.
1385    #[serde(default, skip_serializing_if = "Option::is_none")]
1386    pub domain: Option<String>,
1387    /// Platform-scoped user ids allowed to drive a session. Deliberately
1388    /// STRICTER than the general secret-mask precedents: an EMPTY list means
1389    /// deny-all (every inbound message is rejected), not allow-all — a
1390    /// startup warning is logged when a platform has no allowed users.
1391    #[serde(default)]
1392    pub allow_from: Vec<String>,
1393    /// User ids allowed to run privileged/admin commands. Parsed from day one
1394    /// but UNUSED in the MVP (#452) — no admin commands exist yet; reserved
1395    /// for the approvals/admin phase of epic #447.
1396    #[serde(default)]
1397    pub admin_from: Vec<String>,
1398}
1399
1400/// bamboo-connect platform bridges: drive bamboo sessions from IM platforms.
1401/// Additive/back-compat: an absent `connect` key in `config.json`
1402/// deserializes to an empty platform list — fully inert (#452).
1403#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1404pub struct ConnectConfig {
1405    #[serde(default)]
1406    pub platforms: Vec<ConnectPlatformConfig>,
1407}
1408
1409fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1410    config.platforms.is_empty()
1411}
1412
1413/// One publisher key trusted to sign plugin bundles.
1414///
1415/// `algorithm` is a plain string (not an enum) so an unrecognized future value
1416/// in an old/new config just never matches during verification rather than
1417/// failing to deserialize — additive/forward-compatible, matching this
1418/// crate's other config sections. Only `"ed25519"` is understood today.
1419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1420pub struct TrustedKey {
1421    /// Human-readable label (surfaced in logs/CLI output); purely descriptive.
1422    pub label: String,
1423    /// Signature algorithm. Only `"ed25519"` is currently verified.
1424    pub algorithm: String,
1425    /// Hex-encoded public key (32 raw bytes for ed25519).
1426    pub public_key: String,
1427}
1428
1429/// Raw Ed25519 public anchor committed by the Jiandu plugin publisher.
1430pub const JIANDU_PLUGIN_PUBLIC_KEY: &str =
1431    "bba9d0e5553401125f939bc3d844e891706d579a611fea9215545771d5306676";
1432
1433/// Default trusted host+path prefix: the `bigduu` GitHub org/user's own repos
1434/// (e.g. `github.com/bigduu/Nova/releases/...`).
1435pub(crate) fn default_trusted_hosts() -> Vec<String> {
1436    vec!["github.com/bigduu/".to_string()]
1437}
1438
1439pub use crate::plugin_trust::{PluginTrustConfig, PublisherMode};
1440
1441/// `plugin_trust.enforcement`: the persistent, config-level form of the
1442/// `--insecure` escape hatch (see [`PluginTrustConfig::enforcement`]).
1443///
1444/// Deserialization accepts either the canonical string form (`"strict"` /
1445/// `"off"`, case-insensitive) or a bool-ish alias (`true` == `Strict`,
1446/// `false` == `Off`) for a hand-edited `config.json` — `true`/`false` read
1447/// naturally as "is enforcement on?". The string form is what
1448/// `bamboo config set plugin_trust.enforcement off` writes (and the only
1449/// form the generic dot-path setter's round-trip check accepts on write,
1450/// since this type always *serializes* back out as a string — see
1451/// `bamboo-config`'s `dot_path` module); the bool alias is a read-side
1452/// convenience for whoever edits `config.json` directly.
1453#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1454#[serde(rename_all = "snake_case")]
1455pub enum PluginTrustEnforcement {
1456    /// Secure by default: the host allowlist, signature, and checksum layers
1457    /// are all enforced (each individually waivable via
1458    /// `--allow-untrusted-host` / `--allow-unsigned` / `--allow-unverified`,
1459    /// or all at once via `--insecure`).
1460    #[default]
1461    Strict,
1462    /// Every `url` plugin install/update skips all three trust layers,
1463    /// without needing any per-install flag. Opt-in only — never the
1464    /// default for a fresh or pre-existing config.
1465    Off,
1466}
1467
1468impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1469    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1470    where
1471        D: serde::Deserializer<'de>,
1472    {
1473        #[derive(Deserialize)]
1474        #[serde(untagged)]
1475        enum Repr {
1476            Bool(bool),
1477            Str(String),
1478        }
1479        match Repr::deserialize(deserializer)? {
1480            Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1481            Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1482            Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1483                "strict" => Ok(PluginTrustEnforcement::Strict),
1484                "off" => Ok(PluginTrustEnforcement::Off),
1485                other => Err(serde::de::Error::custom(format!(
1486                    "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1487                     \"off\""
1488                ))),
1489            },
1490        }
1491    }
1492}
1493
1494/// One `trusted_hosts` entry, split into its host and path-prefix
1495/// components (see [`is_host_trusted`]). `path_prefix` is empty for a
1496/// bare-host entry (e.g. `"example.com"`, meaning "any path on this exact
1497/// host") or starts with `/` (e.g. `"/bigduu/"` from `"github.com/bigduu/"`).
1498struct TrustedHostEntry<'a> {
1499    host: &'a str,
1500    path_prefix: &'a str,
1501}
1502
1503/// Split a raw `trusted_hosts` entry at its first `/` into host + path
1504/// components. Entries are compared against ALREADY-lowercased input by the
1505/// caller ([`is_host_trusted`]), so this does no case normalization itself.
1506fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1507    match entry.find('/') {
1508        Some(index) => TrustedHostEntry {
1509            host: &entry[..index],
1510            path_prefix: &entry[index..],
1511        },
1512        None => TrustedHostEntry {
1513            host: entry,
1514            path_prefix: "",
1515        },
1516    }
1517}
1518
1519/// True when `path` matches `prefix` on a `/` path-component boundary, never
1520/// on a raw byte prefix: exactly equal to `prefix`, or `prefix` ends in `/`
1521/// and `path` starts with it, or the character in `path` immediately
1522/// following `prefix` is `/`. An empty `prefix` (a bare-host trusted_hosts
1523/// entry) matches any path.
1524///
1525/// This is what stops a sibling path from passing as a prefix match — e.g.
1526/// entry `github.com/bigduu` (no trailing slash) must NOT match
1527/// `github.com/bigduu-evil/x`: `"/bigduu-evil/x"` starts with `"/bigduu"` as
1528/// raw bytes, but the character right after the prefix is `-`, not `/`, so
1529/// this correctly refuses it.
1530fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1531    if prefix.is_empty() || path == prefix {
1532        return true;
1533    }
1534    if prefix.ends_with('/') {
1535        return path.starts_with(prefix);
1536    }
1537    path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1538}
1539
1540/// Free function backing [`PluginTrustConfig::is_host_trusted`] — exposed
1541/// separately so callers (and tests) can check a candidate host list without
1542/// constructing a full [`PluginTrustConfig`]/[`Config`].
1543///
1544/// Matches on PARSED URL COMPONENTS, not a raw string prefix: `url` must be
1545/// `https`, its `host_str()` (already correct for userinfo — `user@host` or
1546/// `host@evil.com`-style tricks resolve to the real host, not a decoy — and
1547/// for an explicit port, which `host_str()` excludes) must EQUAL a
1548/// `trusted_hosts` entry's host component (case-insensitively, WHOLE host —
1549/// never a `starts_with`), and its (already dot-segment-normalized by
1550/// `Url::parse`) path must match that entry's path-prefix component on a `/`
1551/// boundary (see [`path_matches_prefix`]). A bare-host entry (no `/` in it)
1552/// has an empty path-prefix, so it matches any path but ONLY on that exact
1553/// host.
1554///
1555/// A prior raw-`starts_with` implementation was defeated by (1) gluing a
1556/// trusted bare host into a longer attacker-controlled one, e.g.
1557/// `trusted.example.com` matching `trusted.example.com.evil.com` /
1558/// `trusted.example.comevil.com`, and (2) a sibling path prefix, e.g.
1559/// `github.com/bigduu` (no trailing slash) matching
1560/// `github.com/bigduu-evil/x`. Component-wise matching closes both: host
1561/// comparison is whole-string equality (no gluing possible), and the path
1562/// check enforces a `/` boundary (no sibling-prefix bypass possible).
1563pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1564    let Ok(parsed) = url::Url::parse(url) else {
1565        return false;
1566    };
1567    if parsed.scheme() != "https" {
1568        return false;
1569    }
1570    let Some(host) = parsed.host_str() else {
1571        return false;
1572    };
1573    let host = host.to_ascii_lowercase();
1574    let path = parsed.path();
1575
1576    trusted_hosts.iter().any(|raw_entry| {
1577        let entry = raw_entry.trim().to_ascii_lowercase();
1578        let parsed_entry = parse_trusted_host_entry(&entry);
1579        host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1580    })
1581}
1582
1583/// Host strategy used when the active model context approaches its input limit.
1584///
1585/// `Summary` remains the compatibility default. `MessageSummary` summarizes only
1586/// historical user/assistant prose and removes completed tool exchanges from the
1587/// active model view. `RetrievalWindow` is opt-in and
1588/// archives old exact messages only after the runtime has verified that the
1589/// current-session history capability is callable and the boundary can be
1590/// durably checkpointed.
1591#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1592#[serde(rename_all = "snake_case")]
1593pub enum ContextManagementStrategy {
1594    #[default]
1595    Summary,
1596    MessageSummary,
1597    RetrievalWindow,
1598}
1599
1600/// Explicit fallback used when retrieval-window cannot satisfy a runtime
1601/// precondition. There is deliberately no implicit summary fallback.
1602#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1603#[serde(rename_all = "snake_case")]
1604pub enum ContextManagementFallbackStrategy {
1605    #[default]
1606    None,
1607    Summary,
1608}
1609
1610fn default_retrieval_window_min_recent_user_turns() -> usize {
1611    3
1612}
1613
1614fn default_context_trigger_usage_ratio() -> f64 {
1615    0.80
1616}
1617
1618fn default_context_target_usage_ratio() -> f64 {
1619    0.25
1620}
1621
1622fn default_retrieval_window_trigger_usage_ratio() -> f64 {
1623    0.80
1624}
1625
1626fn default_retrieval_window_target_usage_ratio() -> f64 {
1627    0.60
1628}
1629
1630fn default_history_tool_required() -> bool {
1631    true
1632}
1633
1634/// Selection and safety policy for the opt-in retrieval-window strategy.
1635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1636#[serde(default)]
1637pub struct RetrievalWindowContextConfig {
1638    #[serde(default = "default_retrieval_window_min_recent_user_turns")]
1639    pub min_recent_user_turns: usize,
1640    #[serde(default = "default_retrieval_window_trigger_usage_ratio")]
1641    pub trigger_usage_ratio: f64,
1642    #[serde(default = "default_retrieval_window_target_usage_ratio")]
1643    pub target_usage_ratio: f64,
1644    #[serde(default = "default_history_tool_required")]
1645    pub history_tool_required: bool,
1646    #[serde(default)]
1647    pub fallback_strategy: ContextManagementFallbackStrategy,
1648}
1649
1650impl Default for RetrievalWindowContextConfig {
1651    fn default() -> Self {
1652        Self {
1653            min_recent_user_turns: default_retrieval_window_min_recent_user_turns(),
1654            trigger_usage_ratio: default_retrieval_window_trigger_usage_ratio(),
1655            target_usage_ratio: default_retrieval_window_target_usage_ratio(),
1656            history_tool_required: default_history_tool_required(),
1657            fallback_strategy: ContextManagementFallbackStrategy::None,
1658        }
1659    }
1660}
1661
1662/// Backward-compatible context-management configuration snapshot.
1663#[derive(Debug, Clone, Serialize, PartialEq)]
1664pub struct ContextManagementConfig {
1665    pub strategy: ContextManagementStrategy,
1666    /// Shared upper bound for proactive compression across every strategy.
1667    /// The effective token boundary can be earlier after output/safety reserves.
1668    pub trigger_usage_ratio: f64,
1669    /// Shared post-compression target relative to the model's complete window.
1670    pub target_usage_ratio: f64,
1671    pub retrieval_window: RetrievalWindowContextConfig,
1672}
1673
1674impl Default for ContextManagementConfig {
1675    fn default() -> Self {
1676        Self {
1677            strategy: ContextManagementStrategy::Summary,
1678            trigger_usage_ratio: default_context_trigger_usage_ratio(),
1679            target_usage_ratio: default_context_target_usage_ratio(),
1680            retrieval_window: RetrievalWindowContextConfig::default(),
1681        }
1682    }
1683}
1684
1685impl ContextManagementConfig {
1686    pub fn is_default(&self) -> bool {
1687        *self == Self::default()
1688    }
1689
1690    pub fn validate(&self) -> std::result::Result<(), String> {
1691        if !self.target_usage_ratio.is_finite()
1692            || !self.trigger_usage_ratio.is_finite()
1693            || self.target_usage_ratio < 0.01
1694            || self.target_usage_ratio >= self.trigger_usage_ratio
1695            || self.trigger_usage_ratio > 1.0
1696            || (self.target_usage_ratio * 100.0).floor()
1697                >= (self.trigger_usage_ratio * 100.0).floor()
1698        {
1699            return Err(
1700                "context_management ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1 with distinct whole-percent boundaries"
1701                    .to_string(),
1702            );
1703        }
1704        let policy = &self.retrieval_window;
1705        if policy.min_recent_user_turns == 0 {
1706            return Err(
1707                "context_management.retrieval_window.min_recent_user_turns must be greater than zero"
1708                    .to_string(),
1709            );
1710        }
1711        if !policy.target_usage_ratio.is_finite()
1712            || !policy.trigger_usage_ratio.is_finite()
1713            || policy.target_usage_ratio < 0.01
1714            || policy.target_usage_ratio >= policy.trigger_usage_ratio
1715            || policy.trigger_usage_ratio > 1.0
1716        {
1717            return Err(
1718                "context_management retrieval ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1"
1719                    .to_string(),
1720            );
1721        }
1722        if self.strategy == ContextManagementStrategy::RetrievalWindow
1723            && !policy.history_tool_required
1724        {
1725            return Err(
1726                "retrieval_window requires history_tool_required=true in this release".to_string(),
1727            );
1728        }
1729        Ok(())
1730    }
1731
1732    /// Resolve the common policy onto a runtime-only budget snapshot. Provider
1733    /// limits and durable session overrides remain unchanged; only maintenance
1734    /// ratios are shared, independent of the selected history representation.
1735    pub fn apply_compression_policy(
1736        &self,
1737        mut budget: bamboo_domain::TokenBudget,
1738    ) -> bamboo_domain::TokenBudget {
1739        budget.compression_trigger_percent = (self.trigger_usage_ratio * 100.0).floor() as u8;
1740        budget.compression_target_percent = (self.target_usage_ratio * 100.0).floor() as u8;
1741        budget
1742    }
1743
1744    /// Compatibility accessor. New runtime paths use the dynamically resolved
1745    /// budget's target percentage, also for retrieval archive receipts.
1746    pub fn retrieval_target_usage_percent(&self) -> u8 {
1747        ((self.target_usage_ratio * 100.0).floor() as u8).max(1)
1748    }
1749}
1750
1751impl<'de> Deserialize<'de> for ContextManagementConfig {
1752    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1753    where
1754        D: serde::Deserializer<'de>,
1755    {
1756        #[derive(Default, Deserialize)]
1757        #[serde(default)]
1758        struct Wire {
1759            strategy: ContextManagementStrategy,
1760            trigger_usage_ratio: Option<f64>,
1761            target_usage_ratio: Option<f64>,
1762            retrieval_window: Option<RetrievalWindowContextConfig>,
1763        }
1764
1765        let wire = Wire::deserialize(deserializer)?;
1766        let retrieval_window = wire.retrieval_window.unwrap_or_default();
1767        // Older clients stored ratios under retrieval_window. Preserve stricter
1768        // settings as common limits, while the old default 80/60 adopts 80/25.
1769        // Explicit common fields take precedence on subsequent saves.
1770        let config = Self {
1771            strategy: wire.strategy,
1772            trigger_usage_ratio: wire.trigger_usage_ratio.unwrap_or_else(|| {
1773                default_context_trigger_usage_ratio().min(retrieval_window.trigger_usage_ratio)
1774            }),
1775            target_usage_ratio: wire.target_usage_ratio.unwrap_or_else(|| {
1776                default_context_target_usage_ratio().min(retrieval_window.target_usage_ratio)
1777            }),
1778            retrieval_window,
1779        };
1780        config.validate().map_err(serde::de::Error::custom)?;
1781        Ok(config)
1782    }
1783}
1784
1785/// The provider route and pre-call input proof for semantic patrol.
1786#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1787#[serde(rename_all = "snake_case")]
1788pub enum PatrolModelProtocol {
1789    LlamaCppRawV1,
1790    RegisteredProviderV1,
1791}
1792
1793/// Independent selection for semantic patrol preparation. Absent disables it.
1794#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1795#[serde(deny_unknown_fields)]
1796pub struct PatrolModelConfig {
1797    pub provider: String,
1798    pub model: String,
1799    pub protocol: PatrolModelProtocol,
1800    /// Numeric loopback activation for the raw profile. Registered providers
1801    /// use the endpoint of the configured instance.
1802    #[serde(default, skip_serializing_if = "Option::is_none")]
1803    pub endpoint: Option<std::net::SocketAddr>,
1804    pub max_input_tokens: std::num::NonZeroU32,
1805    pub max_output_tokens: std::num::NonZeroU32,
1806    pub max_evidence_bytes: std::num::NonZeroUsize,
1807    pub max_evidence_refs: std::num::NonZeroUsize,
1808}
1809
1810impl PatrolModelConfig {
1811    pub fn validate(&self) -> Result<(), String> {
1812        if self.provider.is_empty()
1813            || self.provider.len() > 256
1814            || self
1815                .provider
1816                .chars()
1817                .any(|ch| ch.is_whitespace() || ch.is_control())
1818        {
1819            return Err("patrol_model requires an explicit provider identifier".into());
1820        }
1821        let input_limit = match self.protocol {
1822            PatrolModelProtocol::LlamaCppRawV1 => MAX_INPUT_TOKENS,
1823            PatrolModelProtocol::RegisteredProviderV1 => MAX_PROVIDER_INPUT_TOKENS,
1824        };
1825        if self.max_input_tokens.get() > input_limit
1826            || self.max_output_tokens.get() > MAX_OUTPUT_TOKENS
1827            || self.max_evidence_bytes.get() > MAX_EVIDENCE_BYTES
1828            || self.max_evidence_refs.get() > MAX_EVIDENCE_REFS
1829        {
1830            return Err(format!(
1831                "patrol_model limits must be at most {input_limit} input tokens, \
1832                 {MAX_OUTPUT_TOKENS} output tokens, {MAX_EVIDENCE_BYTES} evidence bytes, \
1833                 and {MAX_EVIDENCE_REFS} evidence references"
1834            ));
1835        }
1836        if self
1837            .endpoint
1838            .is_some_and(|endpoint| !endpoint.ip().is_loopback() || endpoint.port() == 0)
1839        {
1840            return Err(
1841                "patrol_model endpoint requires a numeric loopback address and port".into(),
1842            );
1843        }
1844        match self.protocol {
1845            PatrolModelProtocol::LlamaCppRawV1 if self.model == "gpt-oss-20b" => Ok(()),
1846            PatrolModelProtocol::LlamaCppRawV1 => {
1847                Err("llama_cpp_raw_v1 requires the gpt-oss-20b patrol model".into())
1848            }
1849            PatrolModelProtocol::RegisteredProviderV1
1850                if self.endpoint.is_none()
1851                    && !self.model.is_empty()
1852                    && self.model.len() <= 256
1853                    && !self.model.chars().any(|ch| ch.is_whitespace() || ch.is_control()) =>
1854            {
1855                Ok(())
1856            }
1857            PatrolModelProtocol::RegisteredProviderV1 => Err(
1858                "registered_provider_v1 requires a named model and uses the configured provider endpoint".into(),
1859            ),
1860        }
1861    }
1862}
1863
1864/// Main configuration structure for Bamboo agent
1865///
1866/// Contains all settings needed to run the agent, including provider credentials,
1867/// proxy settings, model selection, and server configuration.
1868#[derive(Debug, Clone, Serialize, Deserialize)]
1869#[doc(hidden)]
1870pub struct ConfigValues {
1871    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
1872    #[serde(default)]
1873    pub http_proxy: String,
1874    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
1875    #[serde(default)]
1876    pub https_proxy: String,
1877    /// Proxy authentication credentials
1878    ///
1879    /// Kept in memory only; ordinary config stores `proxy_auth_credential_ref`.
1880    #[serde(skip_serializing)]
1881    pub proxy_auth: Option<ProxyAuth>,
1882    /// Legacy encrypted proxy authentication accepted only for migration.
1883    #[serde(default, skip_serializing_if = "Option::is_none")]
1884    pub proxy_auth_encrypted: Option<String>,
1885    /// Stable credential-store reference for proxy authentication.
1886    #[serde(default, skip_serializing_if = "Option::is_none")]
1887    pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1888    /// Deprecated: Use `providers.copilot.headless_auth` instead
1889    #[serde(default)]
1890    pub headless_auth: bool,
1891
1892    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
1893    #[serde(default = "default_provider")]
1894    pub provider: String,
1895
1896    /// Default model assignments (used when features.provider_model_ref is enabled).
1897    #[serde(default, skip_serializing_if = "Option::is_none")]
1898    pub defaults: Option<DefaultsConfig>,
1899
1900    /// Multi-instance provider configurations keyed by instance id.
1901    ///
1902    /// When `provider_instances` is non-empty, the registry and router prefer
1903    /// instance ids as routing keys. Legacy `providers` / `provider` fields are
1904    /// still supported for backward compatibility; see
1905    /// [`Config::synthesize_legacy_instances`].
1906    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1907    pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1908
1909    /// The default provider instance id used when a request does not specify one.
1910    ///
1911    /// When set, this takes precedence over the legacy `provider` field.
1912    #[serde(default, skip_serializing_if = "Option::is_none")]
1913    pub default_provider_instance: Option<String>,
1914
1915    /// HTTP server configuration
1916    #[serde(default)]
1917    pub server: ServerConfig,
1918
1919    /// Global keyword masking configuration.
1920    ///
1921    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
1922    #[serde(default)]
1923    pub keyword_masking: KeywordMaskingConfig,
1924
1925    /// Anthropic model mapping configuration.
1926    ///
1927    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
1928    #[serde(default)]
1929    pub anthropic_model_mapping: AnthropicModelMapping,
1930
1931    /// Gemini model mapping configuration.
1932    ///
1933    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
1934    #[serde(default)]
1935    pub gemini_model_mapping: GeminiModelMapping,
1936
1937    /// Explicit model selection for offline semantic patrol input preparation.
1938    #[serde(default, skip_serializing_if = "Option::is_none")]
1939    pub patrol_model: Option<PatrolModelConfig>,
1940
1941    /// Request preflight hooks.
1942    ///
1943    /// These hooks can inspect and rewrite outgoing requests before they are sent upstream
1944    /// (e.g. image fallback behavior for text-only models).
1945    #[serde(default)]
1946    pub hooks: HooksConfig,
1947
1948    /// User-configured agent lifecycle command or external script handlers.
1949    ///
1950    /// This is intentionally separate from `hooks`, which is already the
1951    /// provider HTTP request-hook namespace. Lifecycle hooks are snapshotted
1952    /// when an agent run starts.
1953    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1954    pub lifecycle_hooks: LifecycleHooksConfig,
1955
1956    /// Global tool toggles.
1957    ///
1958    /// Any tool listed in `disabled` is omitted from the tool schemas sent to the LLM.
1959    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1960    pub tools: ToolsConfig,
1961
1962    /// Global skill toggles.
1963    ///
1964    /// Any skill listed in `disabled` is excluded from skill context construction and
1965    /// cannot be loaded through the skill runtime tools.
1966    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1967    pub skills: SkillsConfig,
1968
1969    /// User-managed environment variables injected into Bash tool processes.
1970    ///
1971    /// Secret entries are encrypted at rest; plaintext values are hydrated in memory.
1972    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1973    pub env_vars: Vec<EnvVarEntry>,
1974
1975    /// Default work area used when a session has no explicit active workspace.
1976    #[serde(default, skip_serializing_if = "Option::is_none")]
1977    pub default_work_area: Option<DefaultWorkAreaConfig>,
1978
1979    #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
1980    pub worktrees: WorktreeSettings,
1981
1982    /// Access control / password gate configuration.
1983    #[serde(default, skip_serializing_if = "Option::is_none")]
1984    pub access_control: Option<AccessControlConfig>,
1985
1986    /// Feature flags for incremental rollout.
1987    #[serde(default)]
1988    pub features: FeatureFlags,
1989
1990    /// Config-level default per-run token/tool-call/subagent budget (issue
1991    /// #221). `None` fields are unlimited. A per-request `ExecuteRequest`
1992    /// override may only tighten these ceilings, never loosen them; see
1993    /// [`RunBudgetConfig::merged_with_override`].
1994    #[serde(default)]
1995    pub run_budget: RunBudgetConfig,
1996
1997    /// LLM stream liveness and semantic-progress watchdog policy.
1998    #[serde(default)]
1999    pub stream_timeout: StreamTimeoutConfig,
2000
2001    /// Initial-request retry policy for transient transport failures and
2002    /// retryable upstream statuses. Operator-tunable so a flaky or
2003    /// heavily-queued relay can be tolerated without an environment variable
2004    /// and a process restart.
2005    ///
2006    /// `None` means "not configured": the environment-derived policy stays in
2007    /// force, so upgrading a deployment that tunes `BAMBOO_LLM_RETRY_*` does
2008    /// not silently replace those settings with the built-in defaults.
2009    #[serde(default, skip_serializing_if = "Option::is_none")]
2010    pub llm_retry: Option<LlmRetryConfig>,
2011
2012    /// Host-owned strategy for bounding provider-visible conversation context.
2013    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2014    pub context_management: ContextManagementConfig,
2015
2016    /// Remote Cluster Fabric: operator-managed nodes & clusters for deploying
2017    /// `broker-agent` workers locally or over SSH. Additive/back-compat: absent
2018    /// ⇒ empty. SSH secrets are encrypted at rest (see [`crate::cluster_fabric`]).
2019    #[serde(
2020        default,
2021        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2022    )]
2023    pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2024
2025    /// MCP server configuration.
2026    ///
2027    /// Previously persisted in `mcp.json` (now unified into `config.json`).
2028    // On disk we use the mainstream `mcpServers` key (matching Claude Desktop / MCP ecosystem
2029    // conventions). We still accept the legacy `mcp` key for backward compatibility.
2030    #[serde(default, rename = "mcpServers", alias = "mcp")]
2031    pub mcp: bamboo_domain::mcp_config::McpConfig,
2032
2033    /// Notification delivery channels (desktop + push-relay services).
2034    /// Secrets (ntfy token, Bark device key) live in the isolated credential
2035    /// store; only stable references/configured metadata persist here.
2036    #[serde(default)]
2037    pub notifications: NotificationsConfig,
2038
2039    /// bamboo-connect IM-platform bridges (Telegram first, #452 / epic #447).
2040    /// Secrets (each platform's `token`) are encrypted at rest — see
2041    /// [`Config::hydrate_connect_platform_tokens_from_encrypted`] /
2042    /// [`Config::refresh_connect_platform_tokens_encrypted`].
2043    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2044    pub connect: ConnectConfig,
2045
2046    /// Plugin URL-install source-trust policy (host allowlist + ed25519
2047    /// publisher keys). See [`PluginTrustConfig`]'s docs for the three-layer
2048    /// model this stacks with the checksum layer.
2049    #[serde(default)]
2050    pub plugin_trust: PluginTrustConfig,
2051
2052    /// Extension fields stored at the root of `config.json`.
2053    ///
2054    /// This keeps the config forward-compatible and allows unrelated subsystems
2055    /// (e.g. setup UI state) to persist their own keys without getting dropped by
2056    /// typed (de)serialization.
2057    #[serde(default, flatten)]
2058    pub extra: BTreeMap<String, Value>,
2059}
2060
2061impl Default for ConfigValues {
2062    fn default() -> Self {
2063        Self {
2064            http_proxy: String::new(),
2065            https_proxy: String::new(),
2066            proxy_auth: None,
2067            proxy_auth_encrypted: None,
2068            proxy_auth_credential_ref: None,
2069            headless_auth: false,
2070            run_budget: RunBudgetConfig::default(),
2071            stream_timeout: StreamTimeoutConfig::default(),
2072            llm_retry: None,
2073            context_management: ContextManagementConfig::default(),
2074            cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
2075            provider: default_provider(),
2076            provider_instances: HashMap::new(),
2077            default_provider_instance: None,
2078            server: ServerConfig::default(),
2079            keyword_masking: KeywordMaskingConfig::default(),
2080            anthropic_model_mapping: AnthropicModelMapping::default(),
2081            gemini_model_mapping: GeminiModelMapping::default(),
2082            patrol_model: None,
2083            hooks: HooksConfig::default(),
2084            lifecycle_hooks: LifecycleHooksConfig::default(),
2085            tools: ToolsConfig::default(),
2086            skills: SkillsConfig::default(),
2087            env_vars: Vec::new(),
2088            default_work_area: None,
2089            worktrees: WorktreeSettings::default(),
2090            access_control: None,
2091            features: FeatureFlags::default(),
2092            defaults: None,
2093            mcp: bamboo_domain::mcp_config::McpConfig::default(),
2094            notifications: NotificationsConfig::default(),
2095            connect: ConnectConfig::default(),
2096            plugin_trust: PluginTrustConfig::default(),
2097            extra: BTreeMap::new(),
2098        }
2099    }
2100}
2101
2102/// Network-facing root configuration. Flattening preserves the historical
2103/// top-level JSON keys while making the persisted root structurally modular.
2104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2105struct NetworkConfigSection {
2106    #[serde(default)]
2107    http_proxy: String,
2108    #[serde(default)]
2109    https_proxy: String,
2110    #[serde(skip_serializing)]
2111    proxy_auth: Option<ProxyAuth>,
2112    #[serde(default, skip_serializing_if = "Option::is_none")]
2113    proxy_auth_encrypted: Option<String>,
2114    #[serde(default, skip_serializing_if = "Option::is_none")]
2115    proxy_auth_credential_ref: Option<crate::CredentialRef>,
2116    #[serde(default)]
2117    headless_auth: bool,
2118    #[serde(default)]
2119    server: ServerConfig,
2120}
2121
2122#[derive(Debug, Clone, Serialize, Deserialize)]
2123struct ProviderRoutingConfigSection {
2124    #[serde(default = "default_provider")]
2125    provider: String,
2126    #[serde(default, skip_serializing_if = "Option::is_none")]
2127    defaults: Option<DefaultsConfig>,
2128    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2129    provider_instances: HashMap<String, ProviderInstanceConfig>,
2130    #[serde(default, skip_serializing_if = "Option::is_none")]
2131    default_provider_instance: Option<String>,
2132}
2133
2134impl Default for ProviderRoutingConfigSection {
2135    fn default() -> Self {
2136        Self {
2137            provider: default_provider(),
2138            defaults: None,
2139            provider_instances: HashMap::new(),
2140            default_provider_instance: None,
2141        }
2142    }
2143}
2144
2145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2146struct ModelBehaviorConfigSection {
2147    #[serde(default)]
2148    keyword_masking: KeywordMaskingConfig,
2149    #[serde(default)]
2150    anthropic_model_mapping: AnthropicModelMapping,
2151    #[serde(default)]
2152    gemini_model_mapping: GeminiModelMapping,
2153    #[serde(default, skip_serializing_if = "Option::is_none")]
2154    patrol_model: Option<PatrolModelConfig>,
2155    #[serde(default)]
2156    hooks: HooksConfig,
2157    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
2158    lifecycle_hooks: LifecycleHooksConfig,
2159}
2160
2161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2162struct ToolingConfigSection {
2163    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
2164    tools: ToolsConfig,
2165    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
2166    skills: SkillsConfig,
2167    #[serde(default, rename = "mcpServers", alias = "mcp")]
2168    mcp: bamboo_domain::mcp_config::McpConfig,
2169}
2170
2171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2172struct WorkspaceConfigSection {
2173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2174    env_vars: Vec<EnvVarEntry>,
2175    #[serde(default, skip_serializing_if = "Option::is_none")]
2176    default_work_area: Option<DefaultWorkAreaConfig>,
2177    #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
2178    worktrees: WorktreeSettings,
2179    #[serde(default, skip_serializing_if = "Option::is_none")]
2180    access_control: Option<AccessControlConfig>,
2181}
2182
2183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2184struct ExecutionConfigSection {
2185    #[serde(default)]
2186    features: FeatureFlags,
2187    #[serde(default)]
2188    run_budget: RunBudgetConfig,
2189    #[serde(default)]
2190    stream_timeout: StreamTimeoutConfig,
2191    #[serde(default, skip_serializing_if = "Option::is_none")]
2192    llm_retry: Option<LlmRetryConfig>,
2193    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2194    context_management: ContextManagementConfig,
2195    #[serde(
2196        default,
2197        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2198    )]
2199    cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2200}
2201
2202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2203struct IntegrationConfigSection {
2204    #[serde(default)]
2205    notifications: NotificationsConfig,
2206    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2207    connect: ConnectConfig,
2208}
2209
2210#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2211struct PluginSecurityConfigSection {
2212    #[serde(default)]
2213    plugin_trust: PluginTrustConfig,
2214}
2215
2216// Count declarations from the same field list that defines each structural
2217// budgeted type, so the constants cannot drift from the actual structs.
2218macro_rules! count_fields {
2219    ($($field:ident),* $(,)?) => {
2220        <[()]>::len(&[$(count_fields!(@one $field)),*])
2221    };
2222    (@one $field:ident) => { () };
2223}
2224
2225macro_rules! define_counted_struct {
2226    (
2227        $(#[$struct_meta:meta])*
2228        $visibility:vis struct $name:ident {
2229            $(
2230                $(#[$field_meta:meta])*
2231                $field_visibility:vis $field:ident: $field_type:ty
2232            ),* $(,)?
2233        }
2234        count $count_visibility:vis $count_name:ident;
2235    ) => {
2236        $(#[$struct_meta])*
2237        $visibility struct $name {
2238            $(
2239                $(#[$field_meta])*
2240                $field_visibility $field: $field_type,
2241            )*
2242        }
2243
2244        $count_visibility const $count_name: usize = count_fields!($($field),*);
2245    };
2246}
2247
2248define_counted_struct! {
2249    /// The root-only persistence DTO written to `config.json`.
2250    ///
2251    /// Every section is flattened so existing documents keep their historical
2252    /// top-level shape. The structural field count is nevertheless nine rather
2253    /// than the Phase-#39 baseline of 31.
2254    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
2255    struct ConfigRoot {
2256        #[serde(flatten)]
2257        network: NetworkConfigSection,
2258        #[serde(flatten)]
2259        provider_routing: ProviderRoutingConfigSection,
2260        #[serde(flatten)]
2261        model_behavior: ModelBehaviorConfigSection,
2262        #[serde(flatten)]
2263        tooling: ToolingConfigSection,
2264        #[serde(flatten)]
2265        workspace: WorkspaceConfigSection,
2266        #[serde(flatten)]
2267        execution: ExecutionConfigSection,
2268        #[serde(flatten)]
2269        integrations: IntegrationConfigSection,
2270        #[serde(flatten)]
2271        plugin_security: PluginSecurityConfigSection,
2272        #[serde(default, flatten)]
2273        extra: BTreeMap<String, Value>,
2274    }
2275    count pub PERSISTED_ROOT_FIELD_COUNT;
2276}
2277
2278impl From<ConfigValues> for ConfigRoot {
2279    fn from(values: ConfigValues) -> Self {
2280        // Deliberately exhaustive: adding a runtime compatibility field must
2281        // update its persisted section mapping or this conversion stops compiling.
2282        let ConfigValues {
2283            http_proxy,
2284            https_proxy,
2285            proxy_auth,
2286            proxy_auth_encrypted,
2287            proxy_auth_credential_ref,
2288            headless_auth,
2289            provider,
2290            defaults,
2291            provider_instances,
2292            default_provider_instance,
2293            server,
2294            keyword_masking,
2295            anthropic_model_mapping,
2296            gemini_model_mapping,
2297            patrol_model,
2298            hooks,
2299            lifecycle_hooks,
2300            tools,
2301            skills,
2302            env_vars,
2303            default_work_area,
2304            worktrees,
2305            access_control,
2306            features,
2307            run_budget,
2308            stream_timeout,
2309            llm_retry,
2310            context_management,
2311            cluster_fabric,
2312            mcp,
2313            notifications,
2314            connect,
2315            plugin_trust,
2316            extra,
2317        } = values;
2318
2319        Self {
2320            network: NetworkConfigSection {
2321                http_proxy,
2322                https_proxy,
2323                proxy_auth,
2324                proxy_auth_encrypted,
2325                proxy_auth_credential_ref,
2326                headless_auth,
2327                server,
2328            },
2329            provider_routing: ProviderRoutingConfigSection {
2330                provider,
2331                defaults,
2332                provider_instances,
2333                default_provider_instance,
2334            },
2335            model_behavior: ModelBehaviorConfigSection {
2336                keyword_masking,
2337                anthropic_model_mapping,
2338                gemini_model_mapping,
2339                patrol_model,
2340                hooks,
2341                lifecycle_hooks,
2342            },
2343            tooling: ToolingConfigSection { tools, skills, mcp },
2344            workspace: WorkspaceConfigSection {
2345                env_vars,
2346                default_work_area,
2347                worktrees,
2348                access_control,
2349            },
2350            execution: ExecutionConfigSection {
2351                features,
2352                run_budget,
2353                stream_timeout,
2354                llm_retry,
2355                context_management,
2356                cluster_fabric,
2357            },
2358            integrations: IntegrationConfigSection {
2359                notifications,
2360                connect,
2361            },
2362            plugin_security: PluginSecurityConfigSection { plugin_trust },
2363            extra,
2364        }
2365    }
2366}
2367
2368impl From<ConfigRoot> for ConfigValues {
2369    fn from(root: ConfigRoot) -> Self {
2370        // Keep every root and section destructure exhaustive so a newly added
2371        // persisted field cannot be silently omitted from the runtime view.
2372        let ConfigRoot {
2373            network,
2374            provider_routing,
2375            model_behavior,
2376            tooling,
2377            workspace,
2378            execution,
2379            integrations,
2380            plugin_security,
2381            extra,
2382        } = root;
2383        let NetworkConfigSection {
2384            http_proxy,
2385            https_proxy,
2386            proxy_auth,
2387            proxy_auth_encrypted,
2388            proxy_auth_credential_ref,
2389            headless_auth,
2390            server,
2391        } = network;
2392        let ProviderRoutingConfigSection {
2393            provider,
2394            defaults,
2395            provider_instances,
2396            default_provider_instance,
2397        } = provider_routing;
2398        let ModelBehaviorConfigSection {
2399            keyword_masking,
2400            anthropic_model_mapping,
2401            gemini_model_mapping,
2402            patrol_model,
2403            hooks,
2404            lifecycle_hooks,
2405        } = model_behavior;
2406        let ToolingConfigSection { tools, skills, mcp } = tooling;
2407        let WorkspaceConfigSection {
2408            env_vars,
2409            default_work_area,
2410            worktrees,
2411            access_control,
2412        } = workspace;
2413        let ExecutionConfigSection {
2414            features,
2415            run_budget,
2416            stream_timeout,
2417            llm_retry,
2418            context_management,
2419            cluster_fabric,
2420        } = execution;
2421        let IntegrationConfigSection {
2422            notifications,
2423            connect,
2424        } = integrations;
2425        let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2426
2427        Self {
2428            http_proxy,
2429            https_proxy,
2430            proxy_auth,
2431            proxy_auth_encrypted,
2432            proxy_auth_credential_ref,
2433            headless_auth,
2434            provider,
2435            defaults,
2436            provider_instances,
2437            default_provider_instance,
2438            server,
2439            keyword_masking,
2440            anthropic_model_mapping,
2441            gemini_model_mapping,
2442            patrol_model,
2443            hooks,
2444            lifecycle_hooks,
2445            tools,
2446            skills,
2447            env_vars,
2448            default_work_area,
2449            worktrees,
2450            access_control,
2451            features,
2452            run_budget,
2453            stream_timeout,
2454            llm_retry,
2455            context_management,
2456            cluster_fabric,
2457            mcp,
2458            notifications,
2459            connect,
2460            plugin_trust,
2461            extra,
2462        }
2463    }
2464}
2465
2466/// Serialize the root-only durable document.
2467///
2468/// Public `Config` serialization intentionally retains the historical
2469/// compatibility shape, including the legacy `provider` selector. Durable
2470/// instance-native writes are narrower: the explicit instance default is the
2471/// routing authority, so legacy routing fields must not be written back.
2472fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2473    let instance_native = values
2474        .default_provider_instance
2475        .as_ref()
2476        .is_some_and(|id| values.provider_instances.contains_key(id));
2477    let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2478    if instance_native {
2479        if let Some(object) = value.as_object_mut() {
2480            object.remove("provider");
2481            object.remove("providers");
2482        }
2483    }
2484    Ok(value)
2485}
2486
2487define_counted_struct! {
2488    /// Runtime configuration facade. Phase-1 sidecar domains are typed modules;
2489    /// the remaining values keep field-access compatibility through `Deref`.
2490    #[derive(Debug, Clone)]
2491    pub struct Config {
2492        values: ConfigValues,
2493        pub(crate) memory: crate::MemoryConfigModule,
2494        pub(crate) subagents: crate::SubagentsConfigModule,
2495        pub(crate) providers: crate::ProviderConfigsModule,
2496        recovery_status: Option<ConfigRecoveryStatus>,
2497    }
2498    count pub CONFIG_FIELD_COUNT;
2499}
2500
2501/// Auditable structural budgets from Issue #590.
2502pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2503const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2504const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2505
2506impl std::ops::Deref for Config {
2507    type Target = ConfigValues;
2508
2509    fn deref(&self) -> &Self::Target {
2510        &self.values
2511    }
2512}
2513
2514impl std::ops::DerefMut for Config {
2515    fn deref_mut(&mut self) -> &mut Self::Target {
2516        &mut self.values
2517    }
2518}
2519
2520impl Serialize for Config {
2521    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2522    where
2523        S: serde::Serializer,
2524    {
2525        self.to_compatibility_value()
2526            .map_err(serde::ser::Error::custom)?
2527            .serialize(serializer)
2528    }
2529}
2530
2531impl<'de> Deserialize<'de> for Config {
2532    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2533    where
2534        D: serde::Deserializer<'de>,
2535    {
2536        use serde::de::Error;
2537
2538        let mut value = Value::deserialize(deserializer)?;
2539        let object = value
2540            .as_object_mut()
2541            .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2542        let memory = object
2543            .remove("memory")
2544            .map(serde_json::from_value)
2545            .transpose()
2546            .map_err(D::Error::custom)?
2547            .unwrap_or_default();
2548        let subagents = object
2549            .remove("subagents")
2550            .map(serde_json::from_value)
2551            .transpose()
2552            .map_err(D::Error::custom)?
2553            .unwrap_or_default();
2554        let providers = object
2555            .remove("providers")
2556            .map(serde_json::from_value)
2557            .transpose()
2558            .map_err(D::Error::custom)?
2559            .unwrap_or_default();
2560        let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2561        if let Some(model) = &root.model_behavior.patrol_model {
2562            model.validate().map_err(D::Error::custom)?;
2563        }
2564
2565        Ok(Self::from_parts(root.into(), memory, subagents, providers))
2566    }
2567}
2568
2569/// Where a [`ConfigRecoveryStatus`]'s recovered values came from. #153.
2570#[derive(Debug, Clone, PartialEq, Serialize)]
2571#[serde(tag = "kind", rename_all = "snake_case")]
2572pub enum ConfigRecoverySource {
2573    /// Field-by-field salvage from the corrupt file itself
2574    /// ([`Config::salvage_partial`]); `fields` lists the top-level keys that
2575    /// were recovered from the corrupt document (any other field fell back to
2576    /// the backup/default baseline instead).
2577    Salvaged { fields: Vec<String> },
2578    /// Recovered wholesale from a `config.json.bak[.N]` generation
2579    /// (`generation` 0 == `.bak`, 1 == `.bak.1`, …).
2580    Backup { generation: usize },
2581    /// No usable salvage or backup; fell back to built-in defaults.
2582    Defaults,
2583}
2584
2585/// Describes a pending config-corruption recovery (#153, following on from
2586/// #37/#135's quarantine + salvage/backup chain): `config.json` failed to
2587/// parse at load time, the corrupt original was quarantined (copied aside,
2588/// not deleted) to `quarantine_path`, and the owning [`Config`] holds the
2589/// recovered in-memory state instead.
2590///
2591/// [`Config::save_to_dir`] refuses to overwrite `config.json` while
2592/// `confirmed` is `false`, so a user who would rather hand-fix the original
2593/// isn't surprised by an automatic overwrite on the next save. Call
2594/// [`Config::confirm_recovery`] (or [`Config::confirm_recovery_and_save_to_dir`])
2595/// to allow the next save through.
2596#[derive(Debug, Clone, PartialEq, Serialize)]
2597pub struct ConfigRecoveryStatus {
2598    /// Where the recovered values came from.
2599    pub source: ConfigRecoverySource,
2600    /// Absolute path of the preserved copy of the corrupt original
2601    /// (`config.json.corrupted.<nanos>`), or `None` if even the quarantine
2602    /// copy failed (the corrupt original still remains in place at
2603    /// `config.json` itself either way — quarantining copies, it doesn't
2604    /// move — so the guard below still applies).
2605    pub quarantine_path: Option<PathBuf>,
2606    /// Set `true` once the user has explicitly confirmed the recovery; only
2607    /// then may `save_to_dir` persist over the original `config.json`.
2608    pub confirmed: bool,
2609}
2610
2611/// Container for provider-specific configurations
2612///
2613/// Each field is optional, allowing users to configure only the providers they need.
2614#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2615pub struct ProviderConfigs {
2616    /// OpenAI provider configuration
2617    #[serde(skip_serializing_if = "Option::is_none")]
2618    pub openai: Option<OpenAIConfig>,
2619    /// Anthropic provider configuration
2620    #[serde(skip_serializing_if = "Option::is_none")]
2621    pub anthropic: Option<AnthropicConfig>,
2622    /// Google Gemini provider configuration
2623    #[serde(skip_serializing_if = "Option::is_none")]
2624    pub gemini: Option<GeminiConfig>,
2625    /// GitHub Copilot provider configuration
2626    #[serde(skip_serializing_if = "Option::is_none")]
2627    pub copilot: Option<CopilotConfig>,
2628    /// Bodhi proxy provider configuration
2629    #[serde(skip_serializing_if = "Option::is_none")]
2630    pub bodhi: Option<BodhiConfig>,
2631
2632    /// Preserve unknown provider keys (forward compatibility).
2633    #[serde(default, flatten)]
2634    pub extra: BTreeMap<String, Value>,
2635}
2636
2637impl ProviderConfigs {
2638    /// Remove only the known legacy selector and built-in aliases while
2639    /// preserving unknown forward-compatible provider entries in `extra`.
2640    pub fn clear_legacy_builtin_aliases(&mut self) {
2641        self.openai = None;
2642        self.anthropic = None;
2643        self.gemini = None;
2644        self.copilot = None;
2645        self.bodhi = None;
2646        self.extra.remove("provider");
2647    }
2648}
2649
2650/// Feature flags for incremental rollout of new subsystems.
2651#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2652pub struct FeatureFlags {
2653    /// File-authoritative Tickets for a newly bootstrapped Supervisor only.
2654    #[serde(default)]
2655    pub ticket_mutation: bool,
2656    /// Actual fresh one-shot dispatch; independent of Ticket record writes.
2657    #[serde(default)]
2658    pub ticket_dispatch: bool,
2659    /// Enable the ProviderModelRef system (multi-provider + unified model selection).
2660    #[serde(default)]
2661    pub provider_model_ref: bool,
2662    /// Enable MiniLoop-based complexity evaluation and dynamic per-round model switching.
2663    #[serde(default)]
2664    pub dynamic_model_routing: bool,
2665    /// Check a final answer once against this request's recorded tool evidence.
2666    #[serde(default)]
2667    pub final_evidence_check: bool,
2668}
2669
2670/// Default model assignments for specific capabilities.
2671///
2672/// Used when `features.provider_model_ref` is enabled.
2673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2674pub struct DefaultsConfig {
2675    pub chat: bamboo_domain::ProviderModelRef,
2676    #[serde(default, skip_serializing_if = "Option::is_none")]
2677    pub fast: Option<bamboo_domain::ProviderModelRef>,
2678    #[serde(default, skip_serializing_if = "Option::is_none")]
2679    pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2680    #[serde(default, skip_serializing_if = "Option::is_none")]
2681    pub vision: Option<bamboo_domain::ProviderModelRef>,
2682    #[serde(default, skip_serializing_if = "Option::is_none")]
2683    pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2684    /// Model for planning/coordination tasks (task decomposition, architecture).
2685    /// Falls back to `fast`, then `chat`, when unset.
2686    #[serde(default, skip_serializing_if = "Option::is_none")]
2687    pub planning: Option<bamboo_domain::ProviderModelRef>,
2688    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
2689    /// Falls back to `fast`, then `chat`, when unset.
2690    #[serde(default, skip_serializing_if = "Option::is_none")]
2691    pub search: Option<bamboo_domain::ProviderModelRef>,
2692    /// Model for code review tasks.
2693    /// Falls back to `fast`, then `chat`, when unset.
2694    #[serde(default, skip_serializing_if = "Option::is_none")]
2695    pub code_review: Option<bamboo_domain::ProviderModelRef>,
2696    /// Default model for child SubAgent runs.
2697    /// Falls back to `fast`, then `chat` when unset.
2698    #[serde(
2699        default,
2700        skip_serializing_if = "Option::is_none",
2701        alias = "sub_session"
2702    )]
2703    pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2704    /// Per-subagent-type model overrides.
2705    /// Key = subagent_type (e.g. "researcher", "coder"), Value = ProviderModelRef.
2706    /// Falls back to `sub_agent`, then `fast`, then `chat` when no match is found.
2707    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2708    pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2709}
2710
2711/// Request hook configuration.
2712#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2713pub struct HooksConfig {
2714    /// Image fallback behavior for OpenAI-compatible requests (chat/responses).
2715    #[serde(default)]
2716    pub image_fallback: ImageFallbackHookConfig,
2717}
2718
2719/// Default deadline for one lifecycle command hook.
2720pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2721/// Smallest accepted lifecycle hook deadline at the config API boundary.
2722pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2723/// Largest accepted lifecycle hook deadline (10 minutes). Lifecycle hooks run
2724/// inline with agent progress, so they share the same upper bound as Bamboo's
2725/// interactive shell tool instead of allowing an accidental hours-long stall.
2726pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2727
2728/// Stable user-facing event keys accepted by `lifecycle_hooks`.
2729pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 9] = [
2730    "SessionStart",
2731    "UserPromptSubmit",
2732    "PreToolUse",
2733    "PostToolUse",
2734    "Stop",
2735    "SessionEnd",
2736    "PreCompact",
2737    "Notification",
2738    "BambooModelCallUsage",
2739];
2740
2741fn default_lifecycle_hook_timeout_ms() -> u64 {
2742    DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2743}
2744
2745fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2746    *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2747}
2748
2749fn lifecycle_hook_enabled_default() -> bool {
2750    true
2751}
2752
2753fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2754    *value
2755}
2756
2757/// Config-driven agent lifecycle hooks.
2758///
2759/// Event names deliberately preserve the user-facing PascalCase protocol.
2760/// Server-owned events use the same stable schema as engine-owned events.
2761#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2762pub struct LifecycleHooksConfig {
2763    #[serde(default)]
2764    pub enabled: bool,
2765    #[serde(
2766        default,
2767        rename = "SessionStart",
2768        skip_serializing_if = "Vec::is_empty"
2769    )]
2770    pub session_start: Vec<LifecycleHookGroup>,
2771    #[serde(
2772        default,
2773        rename = "UserPromptSubmit",
2774        skip_serializing_if = "Vec::is_empty"
2775    )]
2776    pub user_prompt_submit: Vec<LifecycleHookGroup>,
2777    #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2778    pub pre_tool_use: Vec<LifecycleHookGroup>,
2779    #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2780    pub post_tool_use: Vec<LifecycleHookGroup>,
2781    #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2782    pub stop: Vec<LifecycleHookGroup>,
2783    #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2784    pub session_end: Vec<LifecycleHookGroup>,
2785    #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2786    pub pre_compact: Vec<LifecycleHookGroup>,
2787    #[serde(
2788        default,
2789        rename = "Notification",
2790        skip_serializing_if = "Vec::is_empty"
2791    )]
2792    pub notification: Vec<LifecycleHookGroup>,
2793    #[serde(
2794        default,
2795        rename = "BambooModelCallUsage",
2796        skip_serializing_if = "Vec::is_empty"
2797    )]
2798    pub model_call_usage: Vec<LifecycleHookGroup>,
2799}
2800
2801impl LifecycleHooksConfig {
2802    pub fn is_empty(&self) -> bool {
2803        !self.enabled
2804            && self.session_start.is_empty()
2805            && self.user_prompt_submit.is_empty()
2806            && self.pre_tool_use.is_empty()
2807            && self.post_tool_use.is_empty()
2808            && self.stop.is_empty()
2809            && self.session_end.is_empty()
2810            && self.pre_compact.is_empty()
2811            && self.notification.is_empty()
2812            && self.model_call_usage.is_empty()
2813    }
2814}
2815
2816/// A matcher and its ordered handler list for one lifecycle event.
2817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2818pub struct LifecycleHookGroup {
2819    /// A disabled group remains persisted and editable but is not registered
2820    /// for execution. Missing values default to true for old config files.
2821    #[serde(
2822        default = "lifecycle_hook_enabled_default",
2823        skip_serializing_if = "lifecycle_hook_enabled_is_default"
2824    )]
2825    pub enabled: bool,
2826    #[serde(default, skip_serializing_if = "Option::is_none")]
2827    pub matcher: Option<String>,
2828    #[serde(default)]
2829    pub hooks: Vec<LifecycleHookHandler>,
2830}
2831
2832impl Default for LifecycleHookGroup {
2833    fn default() -> Self {
2834        Self {
2835            enabled: true,
2836            matcher: None,
2837            hooks: Vec::new(),
2838        }
2839    }
2840}
2841
2842/// One configured lifecycle hook handler.
2843///
2844/// The internally tagged representation preserves the existing command JSON
2845/// while allowing handler-specific validation for external scripts.
2846#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2847#[serde(tag = "type", rename_all = "lowercase")]
2848pub enum LifecycleHookHandler {
2849    Command {
2850        command: String,
2851        #[serde(
2852            default = "default_lifecycle_hook_timeout_ms",
2853            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2854        )]
2855        timeout_ms: u64,
2856    },
2857    Script {
2858        path: String,
2859        #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2860        runner: LifecycleScriptRunner,
2861        #[serde(
2862            default = "default_lifecycle_hook_timeout_ms",
2863            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2864        )]
2865        timeout_ms: u64,
2866    },
2867}
2868
2869/// Runtime used to execute a lifecycle script.
2870///
2871/// `auto` infers the language from the file extension and tries the system
2872/// runtimes in a deterministic order. Explicit runners are useful when both
2873/// Node.js and Bun are installed or when a deployment standardizes one binary.
2874#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2875#[serde(rename_all = "lowercase")]
2876pub enum LifecycleScriptRunner {
2877    #[default]
2878    Auto,
2879    Node,
2880    Bun,
2881    Python,
2882    Bash,
2883    PowerShell,
2884    Cmd,
2885}
2886
2887impl LifecycleScriptRunner {
2888    pub fn is_auto(&self) -> bool {
2889        matches!(self, Self::Auto)
2890    }
2891
2892    pub fn as_str(self) -> &'static str {
2893        match self {
2894            Self::Auto => "auto",
2895            Self::Node => "node",
2896            Self::Bun => "bun",
2897            Self::Python => "python",
2898            Self::Bash => "bash",
2899            Self::PowerShell => "powershell",
2900            Self::Cmd => "cmd",
2901        }
2902    }
2903
2904    /// Whether this runner can execute the supplied supported script path.
2905    pub fn supports_path(self, path: &str) -> bool {
2906        let extension = lifecycle_script_extension(path);
2907        match self {
2908            Self::Auto => extension.is_some(),
2909            Self::Node | Self::Bun => {
2910                matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2911            }
2912            Self::Python => matches!(extension.as_deref(), Some("py")),
2913            Self::Bash => matches!(extension.as_deref(), Some("sh")),
2914            Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2915            Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2916        }
2917    }
2918}
2919
2920/// Return the normalized extension when the path names a supported lifecycle
2921/// script.
2922pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2923    let extension = std::path::Path::new(path)
2924        .extension()?
2925        .to_str()?
2926        .to_ascii_lowercase();
2927    matches!(
2928        extension.as_str(),
2929        "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2930    )
2931    .then_some(extension)
2932}
2933
2934impl LifecycleHookHandler {
2935    pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2936        Self::Command {
2937            command: command.into(),
2938            timeout_ms,
2939        }
2940    }
2941
2942    pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2943        Self::Script {
2944            path: path.into(),
2945            runner,
2946            timeout_ms,
2947        }
2948    }
2949
2950    pub fn timeout_ms(&self) -> u64 {
2951        match self {
2952            Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2953        }
2954    }
2955}
2956
2957/// Request override configuration for provider-specific HTTP behavior.
2958///
2959/// Overrides are merged in this order (later wins):
2960/// 1. `common`
2961/// 2. `endpoints[endpoint]`
2962/// 3. matching `rules` (sorted by specificity)
2963#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2964pub struct RequestOverridesConfig {
2965    /// Overrides applied to all endpoints.
2966    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2967    pub common: RequestScopeOverride,
2968    /// Endpoint-specific overrides (`chat_completions`, `responses`, `messages`, etc.).
2969    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2970    pub endpoints: BTreeMap<String, RequestScopeOverride>,
2971    /// Model-conditional overrides.
2972    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2973    pub rules: Vec<ModelRequestRule>,
2974}
2975
2976/// A conditional override rule matching a model pattern.
2977#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2978pub struct ModelRequestRule {
2979    /// Model pattern (exact: `gpt-4o`, prefix wildcard: `gpt-5*`).
2980    pub model_pattern: String,
2981    /// Optional endpoint constraint.
2982    #[serde(default, skip_serializing_if = "Option::is_none")]
2983    pub endpoint: Option<String>,
2984    /// Overrides applied when this rule matches.
2985    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2986    pub scope: RequestScopeOverride,
2987}
2988
2989/// Request overrides applied in a specific scope.
2990#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2991pub struct RequestScopeOverride {
2992    /// Extra or overridden HTTP headers.
2993    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2994    pub headers: BTreeMap<String, TemplateExpr>,
2995    /// JSON body patch operations.
2996    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2997    pub body_patch: Vec<BodyPatch>,
2998}
2999
3000impl RequestScopeOverride {
3001    pub fn is_empty(&self) -> bool {
3002        self.headers.is_empty() && self.body_patch.is_empty()
3003    }
3004}
3005
3006/// Body patch operation.
3007#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3008pub struct BodyPatch {
3009    /// Target path (`foo.bar.0` or `/foo/bar/0`).
3010    pub path: String,
3011    /// Operation type.
3012    #[serde(default)]
3013    pub op: BodyPatchOp,
3014    /// Value for `set` operation.
3015    #[serde(default, skip_serializing_if = "Option::is_none")]
3016    pub value: Option<PatchValue>,
3017}
3018
3019/// Supported body patch operations.
3020#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
3021#[serde(rename_all = "snake_case")]
3022pub enum BodyPatchOp {
3023    #[default]
3024    Set,
3025    Remove,
3026}
3027
3028/// Body patch value: either a template expression or a raw JSON value.
3029#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3030#[serde(untagged)]
3031pub enum PatchValue {
3032    Template(TemplateExpr),
3033    Json(Value),
3034}
3035
3036/// String template expression used by headers/body patch values.
3037#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3038#[serde(untagged)]
3039pub enum TemplateExpr {
3040    /// Shorthand literal value.
3041    Literal(String),
3042    /// Structured template expression.
3043    Structured(TemplateExprSpec),
3044}
3045
3046/// Structured template expression.
3047#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3048#[serde(tag = "type", rename_all = "snake_case")]
3049pub enum TemplateExprSpec {
3050    /// Literal string value.
3051    Literal { value: String },
3052    /// Reference a value from Bamboo env vars.
3053    EnvRef {
3054        name: String,
3055        #[serde(default, skip_serializing_if = "Option::is_none")]
3056        fallback: Option<String>,
3057    },
3058    /// Generate a runtime value.
3059    Generated { generator: GeneratedValue },
3060    /// Format string with placeholders (`{env:NAME}`, `{uuid}`, `{unix_ms}`).
3061    Format { template: String },
3062}
3063
3064/// Supported generated value kinds.
3065#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3066#[serde(rename_all = "snake_case")]
3067pub enum GeneratedValue {
3068    Uuid,
3069    UnixMs,
3070}
3071
3072/// Global tool toggle configuration.
3073#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3074pub struct ToolsConfig {
3075    /// Tool names that are disabled globally.
3076    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3077    pub disabled: Vec<String>,
3078
3079    /// Preserve tool configuration owned by newer Bamboo versions or plugins.
3080    #[serde(default, flatten)]
3081    pub extra: BTreeMap<String, Value>,
3082}
3083
3084impl ToolsConfig {
3085    fn is_empty(&self) -> bool {
3086        self.disabled.is_empty() && self.extra.is_empty()
3087    }
3088}
3089
3090/// Global skill toggle configuration.
3091#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3092pub struct SkillsConfig {
3093    /// Skill IDs that are disabled globally.
3094    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3095    pub disabled: Vec<String>,
3096
3097    /// Preserve skill configuration owned by newer Bamboo versions or plugins.
3098    #[serde(default, flatten)]
3099    pub extra: BTreeMap<String, Value>,
3100}
3101
3102impl SkillsConfig {
3103    fn is_empty(&self) -> bool {
3104        self.disabled.is_empty() && self.extra.is_empty()
3105    }
3106}
3107
3108/// When a request contains image parts but the effective provider path is text-only,
3109/// we can either:
3110/// - error fast (preferred for strict setups), or
3111/// - degrade gracefully by replacing images with a placeholder text.
3112#[derive(Debug, Clone, Serialize, Deserialize)]
3113pub struct ImageFallbackHookConfig {
3114    #[serde(default = "default_true_hooks")]
3115    pub enabled: bool,
3116
3117    /// "placeholder" (default) or "error"
3118    #[serde(default = "default_image_fallback_mode")]
3119    pub mode: String,
3120}
3121
3122impl Default for ImageFallbackHookConfig {
3123    fn default() -> Self {
3124        Self {
3125            enabled: default_true_hooks(),
3126            mode: default_image_fallback_mode(),
3127        }
3128    }
3129}
3130
3131fn default_image_fallback_mode() -> String {
3132    "placeholder".to_string()
3133}
3134
3135fn default_true_hooks() -> bool {
3136    // Default to disabled so image inputs are preserved unless the user explicitly
3137    // opts into fallback rewriting (placeholder/error/ocr).
3138    false
3139}
3140
3141/// OpenAI provider configuration
3142///
3143/// # Example
3144///
3145/// ```json
3146/// "openai": {
3147///   "api_key": "sk-...",
3148///   "base_url": "https://api.openai.com/v1",
3149///   "model": "gpt-4"
3150/// }
3151/// ```
3152pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
3153
3154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3155pub struct OpenAIConfig {
3156    /// OpenAI API key (plaintext, in-memory only).
3157    ///
3158    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3159    #[serde(default, skip_serializing)]
3160    pub api_key: String,
3161    /// Encrypted OpenAI API key (nonce:ciphertext).
3162    #[serde(default, skip_serializing_if = "Option::is_none")]
3163    pub api_key_encrypted: Option<String>,
3164    /// Stable reference to the isolated credential store.
3165    #[serde(default, skip_serializing_if = "Option::is_none")]
3166    pub credential_ref: Option<crate::CredentialRef>,
3167    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3168    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3169    /// `api_key_encrypted` on save (that would bake the secret into
3170    /// config.json). Not (de)serialized. (#253)
3171    #[serde(skip)]
3172    pub api_key_from_env: bool,
3173    /// Custom API base URL (for Azure or self-hosted deployments)
3174    #[serde(skip_serializing_if = "Option::is_none")]
3175    pub base_url: Option<String>,
3176    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
3177    #[serde(skip_serializing_if = "Option::is_none")]
3178    pub model: Option<String>,
3179    /// Fast/cheap model for lightweight tasks (title generation and summarization).
3180    /// Falls back to `model` when not set.
3181    #[serde(default, skip_serializing_if = "Option::is_none")]
3182    pub fast_model: Option<String>,
3183    /// Vision-capable model for image understanding tasks.
3184    /// Falls back to `model` when not set.
3185    #[serde(default, skip_serializing_if = "Option::is_none")]
3186    pub vision_model: Option<String>,
3187    /// Default reasoning effort for OpenAI requests.
3188    #[serde(skip_serializing_if = "Option::is_none")]
3189    pub reasoning_effort: Option<ReasoningEffort>,
3190
3191    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3192    ///
3193    /// Example:
3194    /// ```json
3195    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3196    /// ```
3197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3198    pub responses_only_models: Vec<String>,
3199    /// Optional request overrides (headers/body patches/model rules).
3200    #[serde(default, skip_serializing_if = "Option::is_none")]
3201    pub request_overrides: Option<RequestOverridesConfig>,
3202
3203    /// Preserve unknown keys under `providers.openai`.
3204    #[serde(default, flatten)]
3205    pub extra: BTreeMap<String, Value>,
3206}
3207
3208impl OpenAIConfig {
3209    /// Whether Bamboo may lower its provider-neutral cache plan into GPT-5.6+
3210    /// `prompt_cache_options` and `prompt_cache_breakpoint` fields.
3211    ///
3212    /// This defaults to enabled. OpenAI-compatible upstreams that have not yet
3213    /// implemented the explicit-cache request fields can opt out without
3214    /// disabling `prompt_cache_key` or the upstream's implicit prompt cache.
3215    pub fn explicit_prompt_cache_enabled(&self) -> bool {
3216        self.extra
3217            .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3218            .and_then(Value::as_bool)
3219            .unwrap_or(true)
3220    }
3221}
3222
3223/// Anthropic provider configuration
3224///
3225/// # Example
3226///
3227/// ```json
3228/// "anthropic": {
3229///   "api_key": "sk-ant-...",
3230///   "model": "claude-3-5-sonnet-20241022",
3231///   "max_tokens": 4096
3232/// }
3233/// ```
3234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3235pub struct AnthropicConfig {
3236    /// Anthropic API key (plaintext, in-memory only).
3237    ///
3238    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3239    #[serde(default, skip_serializing)]
3240    pub api_key: String,
3241    /// Encrypted Anthropic API key (nonce:ciphertext).
3242    #[serde(default, skip_serializing_if = "Option::is_none")]
3243    pub api_key_encrypted: Option<String>,
3244    /// Stable reference to the isolated credential store.
3245    #[serde(default, skip_serializing_if = "Option::is_none")]
3246    pub credential_ref: Option<crate::CredentialRef>,
3247    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3248    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3249    /// `api_key_encrypted` on save (that would bake the secret into
3250    /// config.json). Not (de)serialized. (#253)
3251    #[serde(skip)]
3252    pub api_key_from_env: bool,
3253    /// Custom API base URL
3254    #[serde(skip_serializing_if = "Option::is_none")]
3255    pub base_url: Option<String>,
3256    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
3257    #[serde(skip_serializing_if = "Option::is_none")]
3258    pub model: Option<String>,
3259    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3260    /// Falls back to `model` when not set.
3261    #[serde(default, skip_serializing_if = "Option::is_none")]
3262    pub fast_model: Option<String>,
3263    /// Vision-capable model for image understanding tasks.
3264    /// Falls back to `model` when not set.
3265    #[serde(default, skip_serializing_if = "Option::is_none")]
3266    pub vision_model: Option<String>,
3267    /// Maximum tokens in model response
3268    #[serde(skip_serializing_if = "Option::is_none")]
3269    pub max_tokens: Option<u32>,
3270    /// Default reasoning effort for Anthropic requests.
3271    #[serde(skip_serializing_if = "Option::is_none")]
3272    pub reasoning_effort: Option<ReasoningEffort>,
3273    /// Optional request overrides (headers/body patches/model rules).
3274    #[serde(default, skip_serializing_if = "Option::is_none")]
3275    pub request_overrides: Option<RequestOverridesConfig>,
3276
3277    /// Unconditionally replay a prior turn's `reasoning` as a `thinking`
3278    /// content block, regardless of whether bamboo captured a valid signature
3279    /// for it (issue #520).
3280    ///
3281    /// Defaults to `false`/absent, which is REQUIRED for real Anthropic: it
3282    /// requires `thinking` input blocks to carry a signature it minted itself,
3283    /// and bamboo never captures one, so an unconditionally-replayed block is
3284    /// always rejected with a 400 (either because it's foreign — minted by a
3285    /// different provider after a mid-session model switch — or because it's
3286    /// an unsigned copy of Claude's own prior turn).
3287    ///
3288    /// Set this to `true` only when pointing `base_url` at an
3289    /// Anthropic-COMPATIBLE upstream (e.g. GLM's `/anthropic` endpoint) that
3290    /// has the opposite contract: it requires the `thinking` block to be
3291    /// present whenever thinking is enabled, but never validates its
3292    /// signature.
3293    #[serde(default, skip_serializing_if = "Option::is_none")]
3294    pub thinking_replay_always: Option<bool>,
3295
3296    /// Preserve unknown keys under `providers.anthropic`.
3297    #[serde(default, flatten)]
3298    pub extra: BTreeMap<String, Value>,
3299}
3300
3301/// Google Gemini provider configuration
3302///
3303/// # Example
3304///
3305/// ```json
3306/// "gemini": {
3307///   "api_key": "AIza...",
3308///   "model": "gemini-2.0-flash-exp"
3309/// }
3310/// ```
3311#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3312pub struct GeminiConfig {
3313    /// Google AI API key (plaintext, in-memory only).
3314    ///
3315    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3316    #[serde(default, skip_serializing)]
3317    pub api_key: String,
3318    /// Encrypted Google AI API key (nonce:ciphertext).
3319    #[serde(default, skip_serializing_if = "Option::is_none")]
3320    pub api_key_encrypted: Option<String>,
3321    /// Stable reference to the isolated credential store.
3322    #[serde(default, skip_serializing_if = "Option::is_none")]
3323    pub credential_ref: Option<crate::CredentialRef>,
3324    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3325    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3326    /// `api_key_encrypted` on save (that would bake the secret into
3327    /// config.json). Not (de)serialized. (#253)
3328    #[serde(skip)]
3329    pub api_key_from_env: bool,
3330    /// Custom API base URL
3331    #[serde(skip_serializing_if = "Option::is_none")]
3332    pub base_url: Option<String>,
3333    /// Default model to use (e.g., "gemini-2.0-flash-exp")
3334    #[serde(skip_serializing_if = "Option::is_none")]
3335    pub model: Option<String>,
3336    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3337    /// Falls back to `model` when not set.
3338    #[serde(default, skip_serializing_if = "Option::is_none")]
3339    pub fast_model: Option<String>,
3340    /// Vision-capable model for image understanding tasks.
3341    /// Falls back to `model` when not set.
3342    #[serde(default, skip_serializing_if = "Option::is_none")]
3343    pub vision_model: Option<String>,
3344    /// Default reasoning effort for Gemini requests.
3345    #[serde(skip_serializing_if = "Option::is_none")]
3346    pub reasoning_effort: Option<ReasoningEffort>,
3347    /// Optional request overrides (headers/body patches/model rules).
3348    #[serde(default, skip_serializing_if = "Option::is_none")]
3349    pub request_overrides: Option<RequestOverridesConfig>,
3350
3351    /// Preserve unknown keys under `providers.gemini`.
3352    #[serde(default, flatten)]
3353    pub extra: BTreeMap<String, Value>,
3354}
3355
3356/// GitHub Copilot provider configuration
3357///
3358/// # Example
3359///
3360/// ```json
3361/// "copilot": {
3362///   "enabled": true,
3363///   "headless_auth": false,
3364///   "model": "gpt-4o"
3365/// }
3366/// ```
3367#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3368pub struct CopilotConfig {
3369    /// Whether Copilot provider is enabled
3370    #[serde(default)]
3371    pub enabled: bool,
3372    /// Print login URL to console instead of opening browser
3373    #[serde(default)]
3374    pub headless_auth: bool,
3375    /// Default model to use for Copilot (used when clients request the "default" model)
3376    #[serde(skip_serializing_if = "Option::is_none")]
3377    pub model: Option<String>,
3378    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3379    /// Falls back to `model` when not set.
3380    #[serde(default, skip_serializing_if = "Option::is_none")]
3381    pub fast_model: Option<String>,
3382    /// Vision-capable model for image understanding tasks.
3383    /// Falls back to `model` when not set.
3384    #[serde(default, skip_serializing_if = "Option::is_none")]
3385    pub vision_model: Option<String>,
3386    /// Default reasoning effort for Copilot requests.
3387    #[serde(skip_serializing_if = "Option::is_none")]
3388    pub reasoning_effort: Option<ReasoningEffort>,
3389
3390    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3391    ///
3392    /// This is useful for newer Copilot models that only support Responses-style requests.
3393    ///
3394    /// Example:
3395    /// ```json
3396    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3397    /// ```
3398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3399    pub responses_only_models: Vec<String>,
3400    /// Optional request overrides (headers/body patches/model rules).
3401    #[serde(default, skip_serializing_if = "Option::is_none")]
3402    pub request_overrides: Option<RequestOverridesConfig>,
3403
3404    /// Preserve unknown keys under `providers.copilot`.
3405    #[serde(default, flatten)]
3406    pub extra: BTreeMap<String, Value>,
3407}
3408
3409/// Bodhi proxy provider configuration.
3410///
3411/// Routes LLM requests through a bodhi-server instance so that raw provider
3412/// API keys never reach the client.
3413#[derive(Debug, Clone, Serialize, Deserialize)]
3414pub struct BodhiConfig {
3415    /// Bodhi server API key (e.g. "bhi_sk_xxx").  In-memory only.
3416    #[serde(default, skip_serializing)]
3417    pub api_key: String,
3418    /// Encrypted form of the API key stored on disk.
3419    #[serde(default, skip_serializing_if = "Option::is_none")]
3420    pub api_key_encrypted: Option<String>,
3421    /// Stable reference to the isolated credential store.
3422    #[serde(default, skip_serializing_if = "Option::is_none")]
3423    pub credential_ref: Option<crate::CredentialRef>,
3424    /// Bodhi server base URL.
3425    #[serde(skip_serializing_if = "Option::is_none")]
3426    pub base_url: Option<String>,
3427    /// Which upstream provider to route through bodhi ("openai", "anthropic", "gemini").
3428    #[serde(skip_serializing_if = "Option::is_none")]
3429    pub target_provider: Option<String>,
3430    /// Default reasoning effort.
3431    #[serde(skip_serializing_if = "Option::is_none")]
3432    pub reasoning_effort: Option<ReasoningEffort>,
3433
3434    /// Preserve unknown keys.
3435    #[serde(default, flatten)]
3436    pub extra: BTreeMap<String, Value>,
3437}
3438
3439/// Returns the default provider name ("anthropic")
3440fn default_provider() -> String {
3441    "anthropic".to_string()
3442}
3443
3444// ─── Provider Instance Configuration ──────────────────────────────────
3445
3446/// Configuration for a single provider instance.
3447///
3448/// Multiple instances of the same provider type (e.g. two OpenAI accounts)
3449/// can coexist. Each instance is identified by a stable `instance_id` that
3450/// is used as the routing key in [`ProviderModelRef::provider`] and the
3451/// provider registry.
3452///
3453/// # Example (config.json)
3454///
3455/// ```json
3456/// {
3457///   "provider_instances": {
3458///     "openai-work": {
3459///       "provider_type": "openai",
3460///       "label": "OpenAI (Work)",
3461///       "api_key": "sk-...",
3462///       "model": "gpt-4o"
3463///     },
3464///     "openai-personal": {
3465///       "provider_type": "openai",
3466///       "label": "OpenAI (Personal)",
3467///       "api_key": "sk-...",
3468///       "base_url": "https://api.openai.com/v1",
3469///       "model": "gpt-4o-mini"
3470///     }
3471///   },
3472///   "default_provider_instance": "openai-work"
3473/// }
3474/// ```
3475#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3476pub struct ProviderInstanceConfig {
3477    /// Which provider backend this instance targets.
3478    ///
3479    /// Must be one of [`AVAILABLE_PROVIDERS`]: `"openai"`, `"anthropic"`,
3480    /// `"gemini"`, `"copilot"`, `"bodhi"`.
3481    pub provider_type: String,
3482
3483    /// Human-readable label shown in the UI / catalog.
3484    #[serde(default, skip_serializing_if = "Option::is_none")]
3485    pub label: Option<String>,
3486
3487    /// API key (plaintext in memory, encrypted at rest via `api_key_encrypted`).
3488    #[serde(default, skip_serializing)]
3489    pub api_key: String,
3490
3491    /// Encrypted API key (nonce:ciphertext). Written to disk; decrypted into
3492    /// `api_key` on load.
3493    #[serde(default, skip_serializing_if = "Option::is_none")]
3494    pub api_key_encrypted: Option<String>,
3495
3496    /// Stable reference to the isolated credential store.
3497    #[serde(default, skip_serializing_if = "Option::is_none")]
3498    pub credential_ref: Option<crate::CredentialRef>,
3499
3500    /// Custom base URL override.
3501    #[serde(default, skip_serializing_if = "Option::is_none")]
3502    pub base_url: Option<String>,
3503
3504    /// Default chat model for this instance.
3505    #[serde(default, skip_serializing_if = "Option::is_none")]
3506    pub model: Option<String>,
3507
3508    /// Fast/cheap model for lightweight tasks.
3509    #[serde(default, skip_serializing_if = "Option::is_none")]
3510    pub fast_model: Option<String>,
3511
3512    /// Vision-capable model.
3513    #[serde(default, skip_serializing_if = "Option::is_none")]
3514    pub vision_model: Option<String>,
3515
3516    /// Default reasoning effort.
3517    #[serde(default, skip_serializing_if = "Option::is_none")]
3518    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3519
3520    /// Models that must use the Responses API upstream (OpenAI only).
3521    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3522    pub responses_only_models: Vec<String>,
3523
3524    /// Optional request overrides (headers/body patches/model rules).
3525    #[serde(default, skip_serializing_if = "Option::is_none")]
3526    pub request_overrides: Option<RequestOverridesConfig>,
3527
3528    /// Whether this instance is enabled. Disabled instances are skipped
3529    /// during registry construction.
3530    #[serde(default = "default_true")]
3531    pub enabled: bool,
3532
3533    /// Provider-type-specific extra fields preserved through (de)serialization.
3534    #[serde(default, flatten)]
3535    pub extra: BTreeMap<String, Value>,
3536}
3537
3538fn default_true() -> bool {
3539    true
3540}
3541
3542/// Returns the default server port (9562)
3543fn default_port() -> u16 {
3544    9562
3545}
3546
3547/// Returns the default bind address (127.0.0.1)
3548fn default_bind() -> String {
3549    "127.0.0.1".to_string()
3550}
3551
3552/// Returns the default worker count (10)
3553fn default_workers() -> usize {
3554    10
3555}
3556
3557/// Returns the default data directory (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3558fn default_data_dir() -> PathBuf {
3559    super::paths::bamboo_dir()
3560}
3561
3562/// HTTP server configuration
3563#[derive(Debug, Clone, Serialize, Deserialize)]
3564pub struct ServerConfig {
3565    /// Port to listen on
3566    #[serde(default = "default_port")]
3567    pub port: u16,
3568
3569    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
3570    #[serde(default = "default_bind")]
3571    pub bind: String,
3572
3573    /// Static files directory (for Docker mode)
3574    pub static_dir: Option<PathBuf>,
3575
3576    /// Worker count for Actix-web
3577    #[serde(default = "default_workers")]
3578    pub workers: usize,
3579
3580    /// v2 (API v2 transport, #181): optional TLS termination config. When both
3581    /// `cert_file` and `key_file` are given, bamboo terminates TLS itself
3582    /// (rustls, no reverse proxy) and serves `https://` — intended for the
3583    /// public `0.0.0.0` face. When absent, the server keeps the plain `.bind()`
3584    /// / `.listen()` path unchanged (desktop loopback stays plaintext). Missing
3585    /// or unparseable cert/key files are fail-fast at startup, never a silent
3586    /// downgrade to plaintext.
3587    #[serde(default, skip_serializing_if = "Option::is_none")]
3588    pub tls: Option<TlsConfig>,
3589
3590    /// Preserve unknown keys under `server`.
3591    #[serde(default, flatten)]
3592    pub extra: BTreeMap<String, Value>,
3593}
3594
3595impl Default for ServerConfig {
3596    fn default() -> Self {
3597        Self {
3598            port: default_port(),
3599            bind: default_bind(),
3600            static_dir: None,
3601            workers: default_workers(),
3602            tls: None,
3603            extra: BTreeMap::new(),
3604        }
3605    }
3606}
3607
3608/// Manual TLS certificate configuration (current stage; ACME deferred).
3609///
3610/// Both fields point at PEM files: `cert_file` is the full certificate chain
3611/// (leaf → intermediates → root), `key_file` is the matching private key
3612/// (PKCS#8 or RSA). See `docs/api-v2-transport.md` §3.
3613#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3614pub struct TlsConfig {
3615    /// PEM certificate chain (leaf → intermediates → root).
3616    pub cert_file: PathBuf,
3617    /// PEM private key (PKCS#8 or RSA).
3618    pub key_file: PathBuf,
3619}
3620
3621/// Proxy authentication credentials
3622#[derive(Debug, Clone, Serialize, Deserialize)]
3623pub struct ProxyAuth {
3624    /// Proxy username
3625    pub username: String,
3626    /// Proxy password
3627    pub password: String,
3628}
3629
3630/// Parse a boolean value from environment variable strings
3631///
3632/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
3633fn parse_bool_env(value: &str) -> bool {
3634    matches!(
3635        value.trim().to_ascii_lowercase().as_str(),
3636        "1" | "true" | "yes" | "y" | "on"
3637    )
3638}
3639
3640fn expand_user_path(value: &str) -> PathBuf {
3641    let trimmed = value.trim();
3642    if let Some(rest) = trimmed.strip_prefix("~/") {
3643        if let Some(home) = dirs::home_dir() {
3644            return home.join(rest);
3645        }
3646    }
3647    PathBuf::from(trimmed)
3648}
3649
3650impl Default for Config {
3651    fn default() -> Self {
3652        // In-memory defaults ONLY. `default()` must not touch the filesystem or
3653        // environment: it was delegating to `new()` → `from_data_dir(None)`,
3654        // which read config.json from disk, applied BAMBOO_* env overrides, and
3655        // published to the global env-var cache. That made every `..Default::
3656        // default()` struct-update and every test silently disk-dependent and
3657        // let non-server callers clobber the server's in-memory config cache.
3658        // `create_default()` is the pure in-memory constructor; disk loading is
3659        // the explicit job of `new()` / `from_data_dir()`. #38.
3660        Self::create_default()
3661    }
3662}
3663
3664/// Prompt-safe snapshot of configured env vars.
3665#[derive(Debug, Clone, PartialEq, Eq)]
3666pub struct PromptSafeEnvVarEntry {
3667    pub name: String,
3668    pub secret: bool,
3669    pub description: Option<String>,
3670}
3671
3672/// Global cache of user-managed env vars for injection into child processes.
3673///
3674/// Updated whenever the config is loaded or reloaded via [`Config::publish_env_vars`].
3675static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3676
3677static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3678
3679fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3680    ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3681}
3682
3683fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3684    PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3685}
3686
3687impl Config {
3688    fn from_parts(
3689        values: ConfigValues,
3690        memory: Option<MemoryConfig>,
3691        subagents: SubagentsConfig,
3692        providers: ProviderConfigs,
3693    ) -> Self {
3694        Self {
3695            values,
3696            memory: crate::MemoryConfigModule(memory),
3697            subagents: crate::SubagentsConfigModule(subagents),
3698            providers: crate::ProviderConfigsModule(providers),
3699            recovery_status: None,
3700        }
3701    }
3702
3703    /// Crate-internal seam used by the modular section facade.
3704    ///
3705    /// Keeping this conversion here lets the facade exhaustively destructure
3706    /// [`ConfigValues`] in both directions without exposing the compatibility
3707    /// facade's private storage to downstream crates.
3708    pub(crate) fn section_values(&self) -> ConfigValues {
3709        self.values.clone()
3710    }
3711
3712    pub(crate) fn from_section_parts(
3713        values: ConfigValues,
3714        memory: Option<MemoryConfig>,
3715        subagents: SubagentsConfig,
3716        providers: ProviderConfigs,
3717    ) -> Self {
3718        Self::from_parts(values, memory, subagents, providers)
3719    }
3720
3721    /// Compatibility accessor for independently persisted memory settings.
3722    pub fn memory(&self) -> &Option<MemoryConfig> {
3723        &self.memory.0
3724    }
3725
3726    pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3727        &mut self.memory.0
3728    }
3729
3730    /// Compatibility accessor for independently persisted sub-agent settings.
3731    pub fn subagents(&self) -> &SubagentsConfig {
3732        &self.subagents.0
3733    }
3734
3735    pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3736        &mut self.subagents.0
3737    }
3738
3739    /// Compatibility accessor for independently persisted legacy providers.
3740    pub fn providers(&self) -> &ProviderConfigs {
3741        &self.providers.0
3742    }
3743
3744    pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3745        &mut self.providers.0
3746    }
3747
3748    /// Canonicalize only the durable provider view when instance routing is
3749    /// authoritative. Unknown provider entries remain available for forward
3750    /// compatibility.
3751    pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3752        if self
3753            .default_provider_instance
3754            .as_ref()
3755            .is_some_and(|id| self.provider_instances.contains_key(id))
3756        {
3757            self.providers.0.clear_legacy_builtin_aliases();
3758        }
3759    }
3760
3761    /// Build the legacy full-config JSON view used by in-memory patching APIs.
3762    ///
3763    /// Public serde and patch/dot-path callers retain the historical full
3764    /// configuration shape. This value is never the persistence representation:
3765    /// [`Config::save_to_dir`] explicitly writes a root-only DTO plus sidecars.
3766    pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3767        let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3768        let object = value
3769            .as_object_mut()
3770            .expect("ConfigRoot always serializes as a JSON object");
3771        object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3772        object.insert(
3773            "subagents".to_string(),
3774            serde_json::to_value(self.subagents())?,
3775        );
3776        object.insert(
3777            "providers".to_string(),
3778            serde_json::to_value(self.providers())?,
3779        );
3780        Ok(value)
3781    }
3782
3783    /// Load configuration from file with environment variable overrides
3784    ///
3785    /// Configuration loading order:
3786    /// 1. Try loading from `config.json` (`{data_dir}/config.json`)
3787    /// 2. Use defaults
3788    /// 3. Apply environment variable overrides (highest priority)
3789    ///
3790    /// # Environment Variables
3791    ///
3792    /// - `BAMBOO_PORT`: Override server port
3793    /// - `BAMBOO_BIND`: Override bind address
3794    /// - `BAMBOO_DATA_DIR`: Override data directory
3795    /// - `BAMBOO_PROVIDER`: Override default provider
3796    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
3797    /// - `BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION`: Override project durable-memory index prompt injection
3798    /// - `BAMBOO_MEMORY_RELEVANT_RECALL`: Override relevant durable-memory recall prompt injection
3799    /// - `BAMBOO_MEMORY_RELEVANT_RECALL_RERANK`: Override model-based relevant recall reranking
3800    /// - `BAMBOO_MEMORY_PROJECT_FIRST_DREAM`: Override project-first Dream prompt behavior
3801    pub fn new() -> Self {
3802        Self::from_data_dir(None)
3803    }
3804
3805    fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3806        let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3807            Ok(facade) => facade.effective_config(),
3808            Err(error) => {
3809                tracing::warn!(
3810                    error = %error,
3811                    "completed modular configuration is unavailable; refusing legacy-root fallback"
3812                );
3813                Self::create_default()
3814            }
3815        };
3816
3817        if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3818            tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3819            config.proxy_auth = None;
3820        }
3821        if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3822            tracing::warn!(error = %error, "provider credential hydration unavailable");
3823        }
3824        if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3825            tracing::warn!(error = %error, "MCP credential hydration unavailable");
3826        }
3827        if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3828            tracing::warn!(error = %error, "env credential hydration unavailable");
3829            for entry in &mut config.env_vars {
3830                if entry.secret {
3831                    entry.value.clear();
3832                }
3833            }
3834        }
3835        if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3836            tracing::warn!(error = %error, "cluster credential hydration unavailable");
3837            config.clear_cluster_runtime_credentials();
3838        }
3839        if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3840            tracing::warn!(error = %error, "notification credential hydration unavailable");
3841            config.notifications.ntfy.token = None;
3842            config.notifications.bark.device_key = None;
3843        }
3844        if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3845            tracing::warn!(error = %error, "connect credential hydration unavailable");
3846            for platform in &mut config.connect.platforms {
3847                platform.token = None;
3848                platform.app_secret = None;
3849            }
3850        }
3851        if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3852            tracing::warn!(error = %error, "access-control credential hydration unavailable");
3853            config.clear_access_control_runtime_verifiers();
3854        }
3855        if let Some(broker) = config.subagents_mut().broker.as_mut() {
3856            if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3857                tracing::warn!(error = %error, "external broker credential hydration unavailable");
3858                broker.token.clear();
3859            }
3860        }
3861        config.normalize_tool_settings();
3862        config.normalize_skill_settings();
3863        config.normalize_plugin_trust_settings();
3864        config.extra.remove("data_dir");
3865        if apply_env {
3866            config.apply_env_overrides();
3867        }
3868        if publish {
3869            config.publish_env_vars();
3870        }
3871        config
3872    }
3873
3874    /// Load configuration from a specific data directory.
3875    ///
3876    /// Use [`Config::from_data_dir`] (publishes env vars to the global cache, for
3877    /// the context that OWNS the cache — the server bootstrap) or
3878    /// [`Config::from_data_dir_without_publish`] (for non-owning readers that must
3879    /// not clobber the live cache). #40.
3880    ///
3881    /// * `data_dir` - Optional data directory path. If None, uses default (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3882    fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3883        // Determine data_dir early (needed to find config file)
3884        let data_dir = data_dir
3885            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3886            .unwrap_or_else(default_data_dir);
3887
3888        match crate::modular_authority_boundary_present(&data_dir) {
3889            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3890            Ok(false) => {}
3891            Err(error) => {
3892                tracing::warn!(
3893                    error = %error,
3894                    "modular configuration marker is unavailable; refusing legacy-root fallback"
3895                );
3896                let mut config = Self::create_default();
3897                if apply_env {
3898                    config.apply_env_overrides();
3899                }
3900                if publish {
3901                    config.publish_env_vars();
3902                }
3903                return config;
3904            }
3905        }
3906
3907        // Finish any shared manifest-committed credential extraction before
3908        // reading even one member of the transaction, then plan only the
3909        // provider/MCP/root domains. The optional broker has its own planner so
3910        // malformed broker metadata cannot suppress main configuration loading.
3911        let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3912            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3913            .map_err(|error| {
3914                tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3915                error
3916            })
3917            .is_ok();
3918        let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3919            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3920            .map_err(|error| {
3921                tracing::warn!(error = %error, "cluster credential migration unavailable");
3922                error
3923            })
3924            .is_ok();
3925
3926        // A recovery performed by either legacy credential planner may have
3927        // completed the modular split. Reclassify immediately before touching
3928        // config.json so a pending split cannot race this compatibility load
3929        // back into legacy authority.
3930        match crate::modular_authority_boundary_present(&data_dir) {
3931            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3932            Ok(false) => {}
3933            Err(error) => {
3934                tracing::warn!(
3935                    error = %error,
3936                    "modular configuration boundary is unavailable; refusing legacy-root fallback"
3937                );
3938                let mut config = Self::create_default();
3939                if apply_env {
3940                    config.apply_env_overrides();
3941                }
3942                if publish {
3943                    config.publish_env_vars();
3944                }
3945                return config;
3946            }
3947        }
3948
3949        let config_path = data_dir.join("config.json");
3950
3951        let mut config = if config_path.exists() {
3952            if let Ok(content) = std::fs::read_to_string(&config_path) {
3953                Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3954                    // Don't silently discard the user's config on corruption.
3955                    // Quarantine the unparseable file, then recover the MOST recent
3956                    // intent in order: (1) SALVAGE the still-valid fields from the
3957                    // corrupt file (a single bad field shouldn't drop everything),
3958                    // (2) the last-known-good config.json.bak, (3) defaults.
3959                    // #37 / #135. Tag the recovered config with a
3960                    // ConfigRecoveryStatus (unconfirmed) so save_to_dir refuses to
3961                    // overwrite config.json until the caller confirms — the
3962                    // quarantined original stays hand-recoverable until then. #153.
3963                    tracing::warn!(
3964                        "Failed to parse config.json ({}); quarantining it and attempting recovery",
3965                        e
3966                    );
3967                    let quarantine_path = quarantine_corrupt_config(&config_path);
3968                    let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3969                        .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3970                        .or_else(|| {
3971                            Self::load_backup(&data_dir).map(|(cfg, generation)| {
3972                                (cfg, ConfigRecoverySource::Backup { generation })
3973                            })
3974                        })
3975                        .unwrap_or_else(|| {
3976                            tracing::warn!(
3977                                "Could not salvage and no usable config.json.bak; using defaults"
3978                            );
3979                            (Self::create_default(), ConfigRecoverySource::Defaults)
3980                        });
3981                    recovered.recovery_status = Some(ConfigRecoveryStatus {
3982                        source,
3983                        quarantine_path,
3984                        confirmed: false,
3985                    });
3986                    recovered
3987                })
3988            } else {
3989                Self::create_default()
3990            }
3991        } else {
3992            Self::create_default()
3993        };
3994
3995        // Phase-1 registrar migration: an existing sidecar is authoritative;
3996        // when absent, retain the legacy inline value loaded from config.json.
3997        // A malformed sidecar is never rewritten during load and the inline
3998        // value remains available, preventing a bad independent edit from
3999        // erasing the user's last usable configuration.
4000        let mut memory_module = config.memory.clone();
4001        match memory_module.load_sync(&data_dir) {
4002            Ok(true) => config.memory = memory_module,
4003            Ok(false) => {}
4004            Err(error) => tracing::warn!(
4005                "Failed to load memory.json; using legacy config.json memory: {error}"
4006            ),
4007        }
4008        let mut subagents_module = config.subagents.clone();
4009        match subagents_module.load_sync(&data_dir) {
4010            Ok(true) => config.subagents = subagents_module,
4011            Ok(false) => {}
4012            Err(error) => tracing::warn!(
4013                "Failed to load subagents.json; using legacy config.json subagents: {error}"
4014            ),
4015        }
4016        if provider_mcp_ready {
4017            let mut providers_module = config.providers.clone();
4018            match providers_module.load_sync(&data_dir) {
4019                Ok(true) => config.providers = providers_module,
4020                Ok(false) => {}
4021                Err(error) => tracing::warn!(
4022                    "Failed to load providers.json; using legacy config.json providers: {error}"
4023                ),
4024            }
4025        }
4026
4027        // Decrypt encrypted proxy auth into in-memory plaintext form.
4028        config.hydrate_proxy_auth_from_encrypted();
4029        if provider_mcp_ready {
4030            if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
4031                tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
4032                config.proxy_auth = None;
4033            }
4034        }
4035        // Decrypt encrypted provider API keys into in-memory plaintext form.
4036        config.hydrate_provider_api_keys_from_encrypted();
4037        // Decrypt encrypted provider-instance API keys into in-memory plaintext form.
4038        config.hydrate_provider_instance_api_keys_from_encrypted();
4039        // Decrypt encrypted MCP secrets into in-memory plaintext form.
4040        config.hydrate_mcp_secrets_from_encrypted();
4041        if provider_mcp_ready {
4042            if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
4043                tracing::warn!(error = %error, "provider credential hydration unavailable");
4044            }
4045            if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
4046                tracing::warn!(error = %error, "MCP credential hydration unavailable");
4047            }
4048        }
4049        // Decrypt encrypted env vars into in-memory plaintext form.
4050        config.hydrate_env_vars_from_encrypted();
4051        if provider_mcp_ready {
4052            if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
4053                tracing::warn!(error = %error, "env credential hydration unavailable");
4054                for entry in &mut config.env_vars {
4055                    if entry.secret {
4056                        entry.value.clear();
4057                    }
4058                }
4059            }
4060        }
4061        // Cluster migration is deliberately independent from provider/MCP
4062        // readiness. A malformed optional fabric fails only cluster runtime
4063        // authentication, while missing/corrupt refs never fall back to legacy
4064        // ciphertext or an unauthenticated SSH attempt.
4065        if cluster_ready {
4066            if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
4067                tracing::warn!(error = %error, "cluster credential hydration unavailable");
4068                config.clear_cluster_runtime_credentials();
4069            }
4070        } else {
4071            config.clear_cluster_runtime_credentials();
4072        }
4073        // Decrypt the encrypted broker token into in-memory plaintext.
4074        config.hydrate_broker_token_from_encrypted();
4075        // Decrypt encrypted notification-channel secrets into in-memory plaintext.
4076        config.hydrate_notifications_from_encrypted();
4077        if provider_mcp_ready {
4078            if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
4079                tracing::warn!(error = %error, "notification credential hydration unavailable");
4080                config.notifications.ntfy.token = None;
4081                config.notifications.bark.device_key = None;
4082            }
4083        } else {
4084            // A pending or unreadable credential migration means the isolated
4085            // store is not authoritative yet. Legacy plaintext/ciphertext may
4086            // remain on disk for recovery, but notification sinks must not use
4087            // it in this process.
4088            config.notifications.ntfy.token = None;
4089            config.notifications.bark.device_key = None;
4090        }
4091        // Merge the standalone connect.json (#455) onto `config.connect`,
4092        // migrating a legacy inline `connect` key from config.json (#453
4093        // state) when present. MUST run before the token hydration below so
4094        // it decrypts the post-merge ciphertext, not a stale/legacy copy.
4095        config.merge_connect_config(&data_dir);
4096        // One-time (idempotent) sweep of the rotated config.json.bak[.N]
4097        // generations for a legacy embedded `connect` sub-tree left behind by
4098        // a pre-#455 build (#468, follow-up to #457). Independent of whether
4099        // `merge_connect_config` just migrated the CURRENT config.json above —
4100        // an instance that was already migrated by an earlier run of this
4101        // binary has a clean config.json today but may still carry the
4102        // legacy key in an untouched `.bak`/`.bak.1`/`.bak.2` generation, since
4103        // backup rotation only overwrites those on a fresh SAVE. Runs on every
4104        // load but is cheap and a no-op once every generation has been swept.
4105        scrub_legacy_connect_from_config_backups(&data_dir);
4106        // Decrypt encrypted bamboo-connect platform tokens into in-memory plaintext.
4107        config.hydrate_connect_platform_tokens_from_encrypted();
4108        if provider_mcp_ready {
4109            if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
4110                tracing::warn!(error = %error, "connect credential hydration unavailable");
4111                for platform in &mut config.connect.platforms {
4112                    platform.token = None;
4113                    platform.app_secret = None;
4114                }
4115            }
4116        }
4117        if provider_mcp_ready {
4118            if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
4119                tracing::warn!(error = %error, "access-control credential hydration unavailable");
4120                config.clear_access_control_runtime_verifiers();
4121            }
4122        } else {
4123            config.clear_access_control_runtime_verifiers();
4124        }
4125        config.normalize_tool_settings();
4126        config.normalize_skill_settings();
4127        config.normalize_plugin_trust_settings();
4128
4129        // Legacy: `data_dir` is no longer a persisted config field. The data directory is
4130        // derived from runtime (BAMBOO_DATA_DIR or `${HOME}/.bamboo`).
4131        config.extra.remove("data_dir");
4132
4133        // Apply environment variable overrides (highest priority). Skipped by
4134        // one-shot CLI writers (`bamboo init` / `config set`) so transient
4135        // `BAMBOO_*` values are never baked into the persisted config.json.
4136        if apply_env {
4137            config.apply_env_overrides();
4138        }
4139
4140        // Publish env vars to the global cache so Bash tools can inject them —
4141        // ONLY when the caller owns that cache. Non-owning readers pass
4142        // publish=false so they don't clobber the server's live env-var cache.
4143        if publish {
4144            config.publish_env_vars();
4145        }
4146
4147        config
4148    }
4149
4150    /// Apply `BAMBOO_*` environment overrides (highest priority) onto a loaded
4151    /// config. Factored out so one-shot writers can skip it (see
4152    /// [`Config::from_data_dir_without_env`]).
4153    fn apply_env_overrides(&mut self) {
4154        if let Ok(port) = std::env::var("BAMBOO_PORT") {
4155            if let Ok(port) = port.parse() {
4156                self.server.port = port;
4157            }
4158        }
4159
4160        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
4161            self.server.bind = bind;
4162        }
4163
4164        // Note: BAMBOO_DATA_DIR already handled by the caller. In instance
4165        // mode the override may name either an exact instance id or a provider
4166        // type. Type matches are ordered by instance id so the result is
4167        // deterministic even for a multi-account configuration.
4168        if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
4169            let provider = provider.trim().to_string();
4170            if !provider.is_empty() {
4171                self.provider = provider.clone();
4172                if !self.provider_instances.is_empty() {
4173                    let selected = self
4174                        .provider_instances
4175                        .contains_key(&provider)
4176                        .then_some(provider.clone())
4177                        .or_else(|| {
4178                            let mut matching = self
4179                                .provider_instances
4180                                .iter()
4181                                .filter(|(_, instance)| {
4182                                    instance.enabled && instance.provider_type == provider
4183                                })
4184                                .map(|(id, _)| id.clone())
4185                                .collect::<Vec<_>>();
4186                            matching.sort();
4187                            matching.into_iter().next()
4188                        });
4189                    self.default_provider_instance = selected;
4190                }
4191            }
4192        }
4193
4194        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
4195            self.headless_auth = parse_bool_env(&headless);
4196        }
4197
4198        if let Ok(project_prompt_injection) =
4199            std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
4200        {
4201            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4202            memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
4203        }
4204
4205        if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
4206            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4207            memory.relevant_recall = parse_bool_env(&relevant_recall);
4208        }
4209
4210        if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4211            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4212            memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4213        }
4214
4215        if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4216            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4217            memory.project_first_dream = parse_bool_env(&project_first_dream);
4218        }
4219
4220        // Per-provider API keys from the environment (highest priority). Lets a
4221        // 12-factor / secret-manager / --env-file / k8s-Secret deploy supply the
4222        // key at runtime instead of baking a plaintext `api_key` into a mounted
4223        // config.json. The `api_key_from_env` flag keeps `refresh_provider_api_keys_encrypted`
4224        // from re-encrypting these keys into `api_key_encrypted` on a later save,
4225        // so an env key is never persisted to disk. (#253)
4226        if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4227            let key = key.trim();
4228            if !key.is_empty() {
4229                if self.provider_instances.is_empty() {
4230                    let openai = self
4231                        .providers
4232                        .openai
4233                        .get_or_insert_with(OpenAIConfig::default);
4234                    openai.api_key = key.to_string();
4235                    openai.api_key_from_env = true;
4236                } else {
4237                    self.apply_provider_instance_env_key("openai", key);
4238                }
4239            }
4240        }
4241        if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4242            let key = key.trim();
4243            if !key.is_empty() {
4244                if self.provider_instances.is_empty() {
4245                    let anthropic = self
4246                        .providers
4247                        .anthropic
4248                        .get_or_insert_with(AnthropicConfig::default);
4249                    anthropic.api_key = key.to_string();
4250                    anthropic.api_key_from_env = true;
4251                } else {
4252                    self.apply_provider_instance_env_key("anthropic", key);
4253                }
4254            }
4255        }
4256        if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4257            let key = key.trim();
4258            if !key.is_empty() {
4259                if self.provider_instances.is_empty() {
4260                    let gemini = self
4261                        .providers
4262                        .gemini
4263                        .get_or_insert_with(GeminiConfig::default);
4264                    gemini.api_key = key.to_string();
4265                    gemini.api_key_from_env = true;
4266                } else {
4267                    self.apply_provider_instance_env_key("gemini", key);
4268                }
4269            }
4270        }
4271    }
4272
4273    fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4274        for instance in self.provider_instances.values_mut().filter(|instance| {
4275            instance.provider_type == provider_type
4276                && crate::provider_instance_api_key_from_env(instance)
4277        }) {
4278            instance.api_key = key.to_string();
4279            instance.api_key_encrypted = None;
4280        }
4281    }
4282
4283    /// Apply runtime-only `BAMBOO_*` overrides to a config assembled by the
4284    /// modular facade. Facade callers use this after durable section snapshots
4285    /// and credential references have been materialized; one-shot writers keep
4286    /// using the no-env load path so overrides are never persisted.
4287    pub fn apply_runtime_env_overrides(&mut self) {
4288        self.apply_env_overrides();
4289    }
4290
4291    /// Load config from disk AND publish its env vars to the process-global cache
4292    /// (so Bash tools inject them). For the context that OWNS that cache — the
4293    /// server bootstrap. Library / secondary readers that only need to read a
4294    /// value must use [`Config::from_data_dir_without_publish`] instead, or they
4295    /// will clobber the server's live cache with stale disk data (#38 / #40).
4296    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4297        Self::from_data_dir_impl(data_dir, true, true)
4298    }
4299
4300    /// Load config from disk WITHOUT publishing env vars to the global cache.
4301    /// For non-owning readers (e.g. permission storage) that just need a config
4302    /// value and must not clobber the live env-var cache. #40.
4303    pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4304        Self::from_data_dir_impl(data_dir, false, true)
4305    }
4306
4307    /// Load config from disk WITHOUT applying `BAMBOO_*` env-var overrides and
4308    /// WITHOUT publishing to the global cache. For one-shot CLI writers
4309    /// (`bamboo init` / `config set`) that immediately re-save: applying env
4310    /// overrides here would bake transient values (port/bind/provider/memory
4311    /// flags) permanently into config.json. Same corruption-recovery + default
4312    /// fallback as the normal load.
4313    pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4314        Self::from_data_dir_impl(data_dir, false, false)
4315    }
4316
4317    /// Merge the standalone `connect.json` (#455) onto `self.connect`, the
4318    /// load-side counterpart of [`save_connect_config`]. Called once per load,
4319    /// BEFORE [`Config::hydrate_connect_platform_tokens_from_encrypted`] runs,
4320    /// so hydration decrypts the POST-merge ciphertext rather than a stale
4321    /// copy still embedded in `config.json`.
4322    ///
4323    /// - `connect.json` present & parseable: authoritative — OVERWRITES
4324    ///   whatever `self.connect` currently holds. If `self.connect` was ALSO
4325    ///   non-empty (a legacy inline `connect` key still in config.json, e.g.
4326    ///   #453-era state, or written by an older binary), that's a stale
4327    ///   duplicate: log a warning and proactively strip the superseded key
4328    ///   from config.json now (#457) rather than waiting for the next
4329    ///   natural save — cheap, and consistent with not spreading token
4330    ///   ciphertext across files.
4331    /// - `connect.json` present but corrupt/unparsable: fail SAFE for this
4332    ///   security-sensitive feature. Log an error, quarantine the bad file to
4333    ///   `connect.json.bak` (best-effort), and continue with an EMPTY
4334    ///   `ConnectConfig` — never falls back to a legacy config.json copy.
4335    /// - `connect.json` absent & `self.connect` non-empty (pure legacy
4336    ///   state): migrate proactively. Adopt the legacy value (already parsed
4337    ///   into `self`) and persist it: strip the `connect` key from
4338    ///   config.json and write connect.json (#457 — NOT a full
4339    ///   [`Config::save_to_dir`], which would re-encrypt every OTHER secret
4340    ///   in config.json and rotate its backups as a load-time side effect,
4341    ///   even for a read-only command like `bamboo config get`), logged at
4342    ///   info.
4343    /// - `connect.json` absent & `self.connect` empty: nothing to do.
4344    fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4345        let connect_path = data_dir.join("connect.json");
4346        match std::fs::read_to_string(&connect_path) {
4347            Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4348                Ok(connect) => {
4349                    let legacy_key_present = !connect_config_is_empty(&self.connect);
4350                    if legacy_key_present {
4351                        tracing::warn!(
4352                            "config.json still has a legacy `connect` key alongside \
4353                             connect.json; connect.json takes precedence — dropping the \
4354                             stale key from config.json now"
4355                        );
4356                    }
4357                    self.connect = connect;
4358                    if legacy_key_present {
4359                        strip_legacy_connect_key_from_config_json(data_dir);
4360                    }
4361                }
4362                Err(e) => {
4363                    tracing::error!(
4364                        "Failed to parse {:?} ({}); continuing with an empty (inert) \
4365                         connect config instead of falling back to a legacy config.json copy",
4366                        connect_path,
4367                        e
4368                    );
4369                    quarantine_corrupt_connect(&connect_path);
4370                    self.connect = ConnectConfig::default();
4371                }
4372            },
4373            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4374                if !connect_config_is_empty(&self.connect) {
4375                    tracing::info!(
4376                        "Migrating legacy `connect` config from config.json to a \
4377                         standalone connect.json"
4378                    );
4379                    // Narrow migration write (#457): strip only the `connect` key
4380                    // from config.json and write connect.json directly, instead of
4381                    // routing through a full `save_to_dir` (see doc comment above).
4382                    strip_legacy_connect_key_from_config_json(data_dir);
4383                    if let Err(e) = save_connect_config(&self.connect, data_dir) {
4384                        tracing::error!("Failed to write connect.json during migration: {}", e);
4385                    }
4386                }
4387            }
4388            Err(e) => {
4389                tracing::error!(
4390                    "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4391                    connect_path,
4392                    e
4393                );
4394                self.connect = ConnectConfig::default();
4395            }
4396        }
4397    }
4398
4399    /// Deserialize config JSON and run the in-memory hydration + normalization
4400    /// chain. Shared by the primary load and the backup-recovery path (#37).
4401    fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4402        serde_json::from_str::<Config>(content).map(|mut config| {
4403            config.hydrate_proxy_auth_from_encrypted();
4404            config.hydrate_provider_api_keys_from_encrypted();
4405            config.hydrate_provider_instance_api_keys_from_encrypted();
4406            config.hydrate_mcp_secrets_from_encrypted();
4407            config.hydrate_env_vars_from_encrypted();
4408            config.hydrate_cluster_fabric_from_encrypted();
4409            config.hydrate_broker_token_from_encrypted();
4410            config.hydrate_notifications_from_encrypted();
4411            config.hydrate_connect_platform_tokens_from_encrypted();
4412            config.normalize_tool_settings();
4413            config.normalize_skill_settings();
4414            config
4415        })
4416    }
4417
4418    /// Try to recover from the rotated `config.json.bak[.N]` generations (each a
4419    /// last-known-good written before a save) when the primary `config.json` is
4420    /// corrupt. Walks newest -> oldest and returns the first that parses (paired
4421    /// with its generation index, 0 == `.bak`, for [`ConfigRecoverySource::Backup`]);
4422    /// `None` if every generation is missing or also unparseable. #37 / #135.
4423    fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4424        let config_path = data_dir.join("config.json");
4425        for gen in 0..BAK_GENERATIONS {
4426            let backup = backup_path_for(&config_path, gen);
4427            let Ok(content) = std::fs::read_to_string(&backup) else {
4428                continue;
4429            };
4430            match Self::parse_and_hydrate(&content) {
4431                Ok(config) => {
4432                    tracing::info!("Recovered configuration from {:?}", backup);
4433                    return Some((config, gen));
4434                }
4435                Err(e) => {
4436                    tracing::warn!(
4437                        "Backup {:?} is unparseable ({}); trying an older generation",
4438                        backup,
4439                        e
4440                    );
4441                }
4442            }
4443        }
4444        None
4445    }
4446
4447    /// Largest corrupt-object key count we'll attempt to salvage. The overlay loop
4448    /// is O(keys) full-Config deserializes over a growing object (the `extra`
4449    /// catch-all absorbs unknown keys), i.e. O(n²) on a pathological file; cap it
4450    /// so a junk-key-flooded config.json can't stall a load. A real config has a
4451    /// few dozen top-level keys, so this only ever trips on garbage.
4452    const SALVAGE_MAX_KEYS: usize = 512;
4453
4454    /// Best-effort PARTIAL salvage of a corrupt `config.json` (#135): parse it as a
4455    /// generic JSON object and overlay each top-level field onto the richest
4456    /// known-good baseline — the last-known-good `config.json.bak` if present, else
4457    /// a fresh default — keeping only the fields that still yield a valid [`Config`].
4458    /// A single bad field (wrong type, malformed section, …) then keeps the
4459    /// baseline's value instead of discarding ALL the user's other settings.
4460    ///
4461    /// Overlaying onto `.bak` (rather than defaults) means the result is the
4462    /// best-of-both: the backup's complete recent-good state PLUS the corrupt
4463    /// file's still-valid newer edits on top — so salvage is never worse than the
4464    /// plain `.bak` fallback, removing the "sparse salvage defeats a rich backup"
4465    /// hazard. Tried BEFORE the bare `.bak` fallback.
4466    ///
4467    /// Returns the hydrated salvaged config, or `None` when the corrupt file isn't
4468    /// even a JSON object (nothing field-wise to salvage) so the caller falls
4469    /// through to `.bak` / defaults.
4470    ///
4471    /// NOTE: the per-field overlay guarantees a VALID `Config`, not a *maximal* or
4472    /// attribution-perfect one. Deterministic alphabetical key order (serde_json is
4473    /// BTreeMap-backed, no `preserve_order`) means a rename/alias pair like
4474    /// `mcp`/`mcpServers` can drop the second-seen even if it'd be valid alone — the
4475    /// outcome is still a valid config, just not necessarily the richest possible.
4476    ///
4477    /// Returns the hydrated salvaged config paired with the top-level keys that
4478    /// were actually recovered from the corrupt document (used to populate
4479    /// [`ConfigRecoverySource::Salvaged`]).
4480    fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4481        // Must at least be a JSON object; otherwise there's nothing field-wise to
4482        // salvage (a truncated/garbage file just falls through to .bak/defaults).
4483        let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4484        let corrupt_obj = corrupt.as_object()?;
4485        if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4486            tracing::warn!(
4487                "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4488                corrupt_obj.len(),
4489                Self::SALVAGE_MAX_KEYS
4490            );
4491            return None;
4492        }
4493
4494        // Overlay onto the richest known-good baseline: the last-known-good backup
4495        // if it parses, else a fresh default. This makes salvage >= the plain .bak
4496        // fallback in every case.
4497        let mut base = Self::load_backup(data_dir)
4498            .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4499            .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4500        let base_obj = base.as_object_mut()?;
4501
4502        let mut salvaged: Vec<String> = Vec::new();
4503        for (key, value) in corrupt_obj {
4504            let previous = base_obj.insert(key.clone(), value.clone());
4505            // Keep the field iff the WHOLE config still deserializes with it
4506            // overlaid — base is valid before each step, so a failure isolates THIS
4507            // field as the corrupt one (and inter-field constraints are respected).
4508            if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4509                salvaged.push(key.clone());
4510            } else {
4511                match previous {
4512                    Some(prev) => {
4513                        base_obj.insert(key.clone(), prev);
4514                    }
4515                    None => {
4516                        base_obj.remove(key);
4517                    }
4518                }
4519            }
4520        }
4521
4522        tracing::warn!(
4523            "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4524             last-known-good/default value",
4525            salvaged.len(),
4526            salvaged.join(", ")
4527        );
4528
4529        // Re-serialize the rebuilt (all-valid) object and run it back through the
4530        // normal parse+hydrate path so secret-decryption / normalization match a
4531        // clean load exactly.
4532        let rebuilt = serde_json::to_string(&base).ok()?;
4533        Self::parse_and_hydrate(&rebuilt)
4534            .ok()
4535            .map(|config| (config, salvaged))
4536    }
4537
4538    /// Get the effective default model for the currently active provider.
4539    ///
4540    /// When `features.provider_model_ref` is enabled, reads from `defaults.chat`
4541    /// before falling back to legacy provider-specific config.
4542    ///
4543    /// Note: for most providers this is a required config value (returns None when absent).
4544    /// Copilot has a built-in fallback when no model is configured.
4545    pub fn get_model(&self) -> Option<String> {
4546        if self.features.provider_model_ref {
4547            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4548                return Some(model_ref.model.clone());
4549            }
4550        }
4551        let provider = self.effective_default_provider();
4552        if let Some(instance) = self.provider_instances.get(provider) {
4553            return instance
4554                .model
4555                .clone()
4556                .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4557        }
4558        match provider {
4559            "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4560            "anthropic" => self
4561                .providers
4562                .anthropic
4563                .as_ref()
4564                .and_then(|c| c.model.clone()),
4565            "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4566            "copilot" => Some(
4567                self.providers
4568                    .copilot
4569                    .as_ref()
4570                    .and_then(|c| c.model.clone())
4571                    .unwrap_or_else(|| "gpt-4o".to_string()),
4572            ),
4573            _ => None,
4574        }
4575    }
4576
4577    /// Get the fast/cheap model for the currently active provider.
4578    ///
4579    /// When `features.provider_model_ref` is enabled, reads from `defaults.fast`
4580    /// before falling back to legacy provider-specific config.
4581    ///
4582    /// Used for lightweight tasks like title generation and summarization.
4583    /// Falls back to `get_model()` when no fast_model is configured.
4584    pub fn get_fast_model(&self) -> Option<String> {
4585        if self.features.provider_model_ref {
4586            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4587                return Some(model_ref.model.clone());
4588            }
4589        }
4590        let provider = self.effective_default_provider();
4591        let fast = if let Some(instance) = self.provider_instances.get(provider) {
4592            instance.fast_model.clone()
4593        } else {
4594            match provider {
4595                "openai" => self
4596                    .providers
4597                    .openai
4598                    .as_ref()
4599                    .and_then(|c| c.fast_model.clone()),
4600                "anthropic" => self
4601                    .providers
4602                    .anthropic
4603                    .as_ref()
4604                    .and_then(|c| c.fast_model.clone()),
4605                "gemini" => self
4606                    .providers
4607                    .gemini
4608                    .as_ref()
4609                    .and_then(|c| c.fast_model.clone()),
4610                "copilot" => self
4611                    .providers
4612                    .copilot
4613                    .as_ref()
4614                    .and_then(|c| c.fast_model.clone()),
4615                _ => None,
4616            }
4617        };
4618        fast.or_else(|| self.get_model())
4619    }
4620
4621    /// Get the configured task summarization model.
4622    ///
4623    /// When `features.provider_model_ref` is enabled, reads from
4624    /// `defaults.task_summary` before falling back through
4625    /// `defaults.fast` → `defaults.chat`.
4626    ///
4627    /// This is used for conversation/task summarization and context compression.
4628    pub fn get_task_summary_model(&self) -> Option<String> {
4629        if self.features.provider_model_ref {
4630            if let Some(model_ref) = self
4631                .defaults
4632                .as_ref()
4633                .and_then(|d| d.task_summary.as_ref())
4634                .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4635                .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4636            {
4637                return Some(model_ref.model.clone());
4638            }
4639        }
4640
4641        self.get_fast_model()
4642    }
4643
4644    /// Get the configured memory/background summarization model.
4645    ///
4646    /// When `features.provider_model_ref` is enabled, reads from
4647    /// `defaults.memory_background` before falling back through
4648    /// `defaults.fast` → `defaults.chat`.
4649    ///
4650    /// Legacy configuration follows the same dedicated → fast → chat order.
4651    pub fn get_memory_background_model(&self) -> Option<String> {
4652        if self.features.provider_model_ref {
4653            if let Some(model_ref) = self
4654                .defaults
4655                .as_ref()
4656                .and_then(|d| d.memory_background.as_ref())
4657            {
4658                return Some(model_ref.model.clone());
4659            }
4660            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4661                return Some(model_ref.model.clone());
4662            }
4663            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4664                return Some(model_ref.model.clone());
4665            }
4666        }
4667        let configured = self
4668            .memory
4669            .as_ref()
4670            .and_then(|memory| memory.background_model.as_ref())
4671            .map(|value| value.trim())
4672            .filter(|value| !value.is_empty())
4673            .map(ToString::to_string);
4674        configured.or_else(|| self.get_fast_model())
4675    }
4676
4677    /// Resolve the configured default work area path when present.
4678    ///
4679    /// This validates that the configured directory exists, but intentionally
4680    /// returns the stable expanded path rather than the platform-specific
4681    /// canonicalized path. On macOS, `canonicalize()` may rewrite `/var/...`
4682    /// to `/private/var/...`, which is correct at the filesystem layer but
4683    /// undesirable as a user-facing/config-derived workspace path.
4684    pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4685        let raw = self
4686            .default_work_area
4687            .as_ref()
4688            .and_then(|config| config.path.as_ref())
4689            .map(|value| value.trim())
4690            .filter(|value| !value.is_empty())?;
4691
4692        let candidate = expand_user_path(raw);
4693        if candidate.is_absolute() {
4694            let canonical = std::fs::canonicalize(&candidate).ok();
4695            return canonical
4696                .as_ref()
4697                .filter(|path| path.is_dir())
4698                .map(|_| candidate.clone())
4699                .or_else(|| candidate.is_dir().then_some(candidate));
4700        }
4701
4702        let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4703        let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4704        canonical
4705            .as_ref()
4706            .filter(|path| path.is_dir())
4707            .map(|_| from_bamboo_dir.clone())
4708            .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4709            .or_else(|| candidate.is_dir().then_some(candidate))
4710    }
4711
4712    /// Get the vision-capable model for the currently active provider.
4713    ///
4714    /// Used for image understanding tasks.
4715    /// Falls back to the fast model, then the chat model, when no vision model
4716    /// is configured.
4717    pub fn get_vision_model(&self) -> Option<String> {
4718        if self.features.provider_model_ref {
4719            if let Some(model_ref) = self
4720                .defaults
4721                .as_ref()
4722                .and_then(|defaults| defaults.vision.as_ref())
4723                .or_else(|| {
4724                    self.defaults
4725                        .as_ref()
4726                        .and_then(|defaults| defaults.fast.as_ref())
4727                })
4728                .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4729            {
4730                return Some(model_ref.model.clone());
4731            }
4732        }
4733        let provider = self.effective_default_provider();
4734        let vision = if let Some(instance) = self.provider_instances.get(provider) {
4735            instance.vision_model.clone()
4736        } else {
4737            match provider {
4738                "openai" => self
4739                    .providers
4740                    .openai
4741                    .as_ref()
4742                    .and_then(|c| c.vision_model.clone()),
4743                "anthropic" => self
4744                    .providers
4745                    .anthropic
4746                    .as_ref()
4747                    .and_then(|c| c.vision_model.clone()),
4748                "gemini" => self
4749                    .providers
4750                    .gemini
4751                    .as_ref()
4752                    .and_then(|c| c.vision_model.clone()),
4753                "copilot" => self
4754                    .providers
4755                    .copilot
4756                    .as_ref()
4757                    .and_then(|c| c.vision_model.clone()),
4758                _ => None,
4759            }
4760        };
4761        vision.or_else(|| self.get_fast_model())
4762    }
4763
4764    /// Get the default reasoning effort for the chat role.
4765    ///
4766    /// A role-specific preference wins over the provider-instance default.
4767    pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4768        if self.features.provider_model_ref {
4769            if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4770                return chat
4771                    .reasoning_effort
4772                    .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4773            }
4774        }
4775        self.reasoning_effort_for_key(self.effective_default_provider())
4776    }
4777
4778    /// Resolve the configured default reasoning effort for a provider routing key.
4779    ///
4780    /// The key may be a multi-instance provider id (for example `"copilot-work"`)
4781    /// or a legacy provider type (for example `"openai"`). In multi-instance mode
4782    /// the per-instance `reasoning_effort` lives under `provider_instances[<id>]`,
4783    /// so we resolve instance ids there first; otherwise we fall back to the
4784    /// legacy per-provider config. Both the execute path
4785    /// ([`crate`]'s `get_reasoning_effort_for_provider`) and the session-create
4786    /// path ([`Self::get_reasoning_effort`]) delegate here so the two cannot drift.
4787    pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4788        let trimmed = key.trim();
4789        if trimmed.is_empty() {
4790            return None;
4791        }
4792
4793        // Multi-instance mode: the routing key is an instance id.
4794        if let Some(instance) = self.provider_instances.get(trimmed) {
4795            return instance.reasoning_effort;
4796        }
4797
4798        // Legacy mode: the routing key is a provider type.
4799        match trimmed {
4800            "openai" => self
4801                .providers
4802                .openai
4803                .as_ref()
4804                .and_then(|c| c.reasoning_effort),
4805            "anthropic" => self
4806                .providers
4807                .anthropic
4808                .as_ref()
4809                .and_then(|c| c.reasoning_effort),
4810            "gemini" => self
4811                .providers
4812                .gemini
4813                .as_ref()
4814                .and_then(|c| c.reasoning_effort),
4815            "copilot" => self
4816                .providers
4817                .copilot
4818                .as_ref()
4819                .and_then(|c| c.reasoning_effort),
4820            "bodhi" => self
4821                .providers
4822                .bodhi
4823                .as_ref()
4824                .and_then(|c| c.reasoning_effort),
4825            _ => None,
4826        }
4827    }
4828
4829    /// Get exact disabled tool references for catalog-aware resolution.
4830    ///
4831    /// References remain exact here: catalog-aware filtering resolves an exact
4832    /// registered name before applying legacy/builtin alias fallback. Eagerly
4833    /// rewriting `apply_patch` to `Edit`, for example, would make an exact
4834    /// custom `apply_patch` registration indistinguishable from the builtin.
4835    pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4836        self.tools
4837            .disabled
4838            .iter()
4839            .map(|name| name.trim())
4840            .filter(|name| !name.is_empty())
4841            .map(str::to_string)
4842            .collect()
4843    }
4844
4845    /// Legacy normalized-name facade retained for source compatibility.
4846    ///
4847    /// New execution/catalog code must use [`Self::disabled_tool_references`]
4848    /// so an exact registered alias can be resolved before fallback.
4849    pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4850        self.disabled_tool_references()
4851            .into_iter()
4852            .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4853            .collect()
4854    }
4855
4856    /// Normalize tool settings (trim / dedupe / sort).
4857    pub fn normalize_tool_settings(&mut self) {
4858        self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4859    }
4860
4861    /// Get normalized disabled skill IDs.
4862    pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4863        self.skills
4864            .disabled
4865            .iter()
4866            .map(|id| id.trim())
4867            .filter(|id| !id.is_empty())
4868            .map(|id| id.to_string())
4869            .collect()
4870    }
4871
4872    /// Normalize skill settings (trim / dedupe / sort).
4873    pub fn normalize_skill_settings(&mut self) {
4874        self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4875    }
4876
4877    /// Normalize `plugin_trust.trusted_hosts` entries (trim / lowercase / drop
4878    /// empties) so a hand-edited `config.json` doesn't silently accumulate
4879    /// mixed-case or whitespace-padded entries. [`is_host_trusted`] itself
4880    /// already matches case-insensitively regardless of how an entry is
4881    /// stored, so this is defense in depth / a canonical on-disk form, not
4882    /// the source of the security fix — that's the host/path-component
4883    /// matching in [`is_host_trusted`] itself.
4884    pub fn normalize_plugin_trust_settings(&mut self) {
4885        self.plugin_trust.trusted_hosts = self
4886            .plugin_trust
4887            .trusted_hosts
4888            .iter()
4889            .map(|entry| entry.trim().to_ascii_lowercase())
4890            .filter(|entry| !entry.is_empty())
4891            .collect();
4892    }
4893
4894    /// Return the effective default provider key.
4895    ///
4896    /// Prefers `default_provider_instance` when set; falls back to the
4897    /// legacy `provider` string.
4898    pub fn effective_default_provider(&self) -> &str {
4899        self.default_provider_instance
4900            .as_deref()
4901            .unwrap_or(&self.provider)
4902    }
4903
4904    /// Whether provider instances are configured (new multi-instance path).
4905    pub fn has_provider_instances(&self) -> bool {
4906        !self.provider_instances.is_empty()
4907    }
4908
4909    /// Build a flat map of all env vars with non-empty values (for process injection).
4910    pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4911        self.env_vars
4912            .iter()
4913            .filter(|e| !e.value.trim().is_empty())
4914            .map(|e| (e.name.clone(), e.value.clone()))
4915            .collect()
4916    }
4917
4918    fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4919        self.env_vars
4920            .iter()
4921            .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4922            .map(|entry| PromptSafeEnvVarEntry {
4923                name: entry.name.clone(),
4924                secret: entry.secret,
4925                description: entry
4926                    .description
4927                    .as_ref()
4928                    .map(|value| value.trim().to_string())
4929                    .filter(|value| !value.is_empty()),
4930            })
4931            .collect()
4932    }
4933
4934    /// Update the global env vars cache (called on config load / reload).
4935    pub fn publish_env_vars(&self) {
4936        let map = self.env_vars_as_map();
4937
4938        #[cfg(any(test, feature = "test-utils"))]
4939        if crate::test_support::env_vars_cache_override_is_active() {
4940            crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4941            return;
4942        }
4943
4944        let mut env_guard = env_vars_cache().write().recover_poison();
4945        *env_guard = map;
4946
4947        let prompt_safe = self.prompt_safe_env_vars();
4948        let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4949        *prompt_guard = prompt_safe;
4950    }
4951
4952    /// Read the current env vars snapshot (called by Bash tool at process spawn time).
4953    pub fn current_env_vars() -> HashMap<String, String> {
4954        #[cfg(any(test, feature = "test-utils"))]
4955        if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4956            return env_vars;
4957        }
4958
4959        env_vars_cache().read().recover_poison().clone()
4960    }
4961
4962    /// Read the current prompt-safe env var snapshot (names + metadata only; no secret values).
4963    pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4964        #[cfg(any(test, feature = "test-utils"))]
4965        if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4966            return env_vars;
4967        }
4968
4969        prompt_safe_env_vars_cache().read().recover_poison().clone()
4970    }
4971
4972    /// Create a default configuration without loading from file
4973    fn create_default() -> Self {
4974        Self::from_parts(
4975            ConfigValues {
4976                http_proxy: String::new(),
4977                https_proxy: String::new(),
4978                proxy_auth: None,
4979                proxy_auth_encrypted: None,
4980                proxy_auth_credential_ref: None,
4981                headless_auth: false,
4982                run_budget: RunBudgetConfig::default(),
4983                stream_timeout: StreamTimeoutConfig::default(),
4984                llm_retry: None,
4985                context_management: ContextManagementConfig::default(),
4986                cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4987                provider: default_provider(),
4988                provider_instances: HashMap::new(),
4989                default_provider_instance: None,
4990                server: ServerConfig::default(),
4991                keyword_masking: KeywordMaskingConfig::default(),
4992                anthropic_model_mapping: AnthropicModelMapping::default(),
4993                gemini_model_mapping: GeminiModelMapping::default(),
4994                patrol_model: None,
4995                hooks: HooksConfig::default(),
4996                lifecycle_hooks: LifecycleHooksConfig::default(),
4997                tools: ToolsConfig::default(),
4998                skills: SkillsConfig::default(),
4999                env_vars: Vec::new(),
5000                default_work_area: None,
5001                worktrees: WorktreeSettings::default(),
5002                access_control: None,
5003                features: FeatureFlags::default(),
5004                defaults: None,
5005                mcp: bamboo_domain::mcp_config::McpConfig::default(),
5006                notifications: NotificationsConfig::default(),
5007                connect: ConnectConfig::default(),
5008                plugin_trust: PluginTrustConfig::default(),
5009                extra: BTreeMap::new(),
5010            },
5011            None,
5012            SubagentsConfig::default(),
5013            ProviderConfigs::default(),
5014        )
5015    }
5016
5017    /// Get the full server address (bind:port)
5018    pub fn server_addr(&self) -> String {
5019        format!("{}:{}", self.server.bind, self.server.port)
5020    }
5021
5022    /// Save configuration to disk
5023    pub fn save(&self) -> Result<()> {
5024        self.save_to_dir(default_data_dir())
5025    }
5026
5027    /// Persist only the memory module, leaving every other config file untouched.
5028    /// Legacy layouts only; modular layouts require a revisioned section update.
5029    pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5030        self.memory.save_sync(data_dir)
5031    }
5032
5033    /// Persist only the sub-agent module, leaving every other config file untouched.
5034    /// Legacy layouts only; modular layouts require a revisioned section update.
5035    pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5036        self.subagents.save_sync(data_dir)
5037    }
5038
5039    /// Persist only provider configuration. Provider plaintext keys are first
5040    /// refreshed into their encrypted at-rest representation.
5041    /// Legacy layouts only; modular layouts require a revisioned section update.
5042    pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5043        crate::config_module::ensure_legacy_module_layout(data_dir)?;
5044        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5045        let mut config = self.clone();
5046        config.clear_legacy_provider_aliases_for_instance_mode();
5047        config.refresh_provider_api_keys_encrypted()?;
5048        config.providers.save_sync(data_dir)
5049    }
5050
5051    /// Build the metadata-only documents used by a provider credential
5052    /// transaction. Nothing is written here: the migration journal owns the
5053    /// durable commit and publishes both documents together with credentials.
5054    pub(crate) fn prepare_provider_transaction_documents(
5055        &self,
5056        provider_document: &[u8],
5057    ) -> Result<(Vec<u8>, Vec<u8>)> {
5058        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5059        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5060            anyhow::bail!(
5061                "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
5062                status.source
5063            );
5064        }
5065
5066        let mut to_save = self.clone();
5067        to_save.clear_legacy_provider_aliases_for_instance_mode();
5068        to_save.extra.remove("data_dir");
5069        to_save.extra.remove("model");
5070        to_save.refresh_encrypted_secrets()?;
5071        to_save.ensure_provider_instance_credentials_isolated()?;
5072        to_save.sanitize_mcp_credential_refs_for_disk();
5073        to_save.sanitize_env_vars_for_disk();
5074        to_save.sanitize_notifications_for_disk();
5075        to_save.sanitize_cluster_fabric_for_disk();
5076        to_save.assign_connect_platform_ids();
5077        to_save.normalize_tool_settings();
5078        to_save.normalize_skill_settings();
5079
5080        let mut root = durable_root_value(to_save.values.clone())
5081            .context("Failed to serialize root config DTO to JSON")?;
5082        if let Some(object) = root.as_object_mut() {
5083            object.remove("connect");
5084        }
5085        let root = serde_json::to_vec_pretty(&root)?;
5086
5087        let mut providers = to_save.providers.0.clone();
5088        macro_rules! sanitize_provider {
5089            ($field:ident) => {
5090                if let Some(provider) = providers.$field.as_mut() {
5091                    if !provider.api_key.trim().is_empty()
5092                        && !provider.api_key_from_env
5093                        && provider.credential_ref.is_none()
5094                    {
5095                        anyhow::bail!(
5096                            "provider secret requires credential transaction before persistence"
5097                        );
5098                    }
5099                    provider.api_key_encrypted = None;
5100                }
5101            };
5102        }
5103        sanitize_provider!(openai);
5104        sanitize_provider!(anthropic);
5105        sanitize_provider!(gemini);
5106        if let Some(provider) = providers.bodhi.as_mut() {
5107            if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
5108                anyhow::bail!("provider secret requires credential transaction before persistence");
5109            }
5110            provider.api_key_encrypted = None;
5111        }
5112
5113        let existing_provider_value = if provider_document.is_empty() {
5114            None
5115        } else {
5116            Some(
5117                serde_json::from_slice::<Value>(provider_document)
5118                    .context("provider metadata document is invalid")?,
5119            )
5120        };
5121        let provider_value = match existing_provider_value {
5122            Some(Value::Object(mut envelope))
5123                if envelope.contains_key("schema_version")
5124                    || envelope.contains_key("revision")
5125                    || envelope.contains_key("data") =>
5126            {
5127                let revision = envelope
5128                    .get("revision")
5129                    .and_then(Value::as_u64)
5130                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5131                let schema_version = envelope
5132                    .get("schema_version")
5133                    .and_then(Value::as_u64)
5134                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5135                if schema_version != 1 || !envelope.contains_key("data") {
5136                    anyhow::bail!("provider revision envelope is unsupported");
5137                }
5138                let revision = revision
5139                    .checked_add(1)
5140                    .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
5141                envelope.insert("revision".into(), Value::from(revision));
5142                envelope.insert("data".into(), serde_json::to_value(providers)?);
5143                Value::Object(envelope)
5144            }
5145            Some(_) | None => serde_json::to_value(providers)?,
5146        };
5147        Ok((root, serde_json::to_vec_pretty(&provider_value)?))
5148    }
5149
5150    /// The pending config-corruption recovery, if `config.json` failed to
5151    /// parse on load and the recovery hasn't been confirmed yet. `None` on
5152    /// every clean load. #153.
5153    pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
5154        self.recovery_status.as_ref()
5155    }
5156
5157    /// Confirm a pending recovery, allowing the next [`Config::save`] /
5158    /// [`Config::save_to_dir`] to overwrite the quarantined-corrupt
5159    /// `config.json` with this recovered state. No-op if there's no pending
5160    /// recovery. Prefer [`Config::confirm_recovery_and_save_to_dir`], which
5161    /// also persists and clears the flag in one step. #153.
5162    pub fn confirm_recovery(&mut self) {
5163        if let Some(status) = self.recovery_status.as_mut() {
5164            status.confirmed = true;
5165        }
5166    }
5167
5168    /// Confirm a pending recovery AND persist it in one step: marks it
5169    /// confirmed (satisfying the [`Config::save_to_dir`] guard), writes the
5170    /// recovered state to `config.json`, then clears `recovery_status`
5171    /// entirely — once this succeeds the config is no longer "pending
5172    /// confirmation", it's just the normal on-disk config. Errors (and
5173    /// leaves `recovery_status` untouched) if there's nothing pending, or if
5174    /// the save itself fails. #153.
5175    pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
5176        if self.recovery_status.is_none() {
5177            anyhow::bail!("No pending config-corruption recovery to confirm");
5178        }
5179        self.confirm_recovery();
5180        self.save_to_dir(data_dir)?;
5181        self.recovery_status = None;
5182        Ok(())
5183    }
5184
5185    /// Assign a stable [`ConnectPlatformConfig::id`] to every `connect.platforms`
5186    /// entry that doesn't already have one (#496).
5187    ///
5188    /// Migration-on-write: [`Config::save_to_dir`] always calls this on its
5189    /// internal save-copy before persisting, so every path that writes
5190    /// `connect.json` gets ids backfilled. Callers that mutate the *live*
5191    /// in-memory config as part of a save (e.g. the server's settings-PATCH
5192    /// handler) should also call this directly on that in-memory value
5193    /// before responding, so a client that echoes the response straight
5194    /// back round-trips the id immediately rather than only after the next
5195    /// reload/restart. Never called from load — a config that's never saved
5196    /// again (e.g. one sitting in an unconfirmed-recovery state, see #493)
5197    /// is never rewritten just to backfill ids. An entry that already has
5198    /// an id keeps it unchanged; ids are never reassigned or deduplicated
5199    /// once set.
5200    pub fn assign_connect_platform_ids(&mut self) {
5201        for platform in &mut self.connect.platforms {
5202            if platform.id.is_none() {
5203                platform.id = Some(uuid::Uuid::new_v4().to_string());
5204            }
5205        }
5206    }
5207
5208    /// Save configuration to disk under the provided data directory.
5209    ///
5210    /// Root configuration is stored as `{data_dir}/config.json`; extracted
5211    /// memory, sub-agent, and provider modules are stored in sibling sidecars.
5212    ///
5213    /// Refuses to write when this config carries an unconfirmed
5214    /// [`ConfigRecoveryStatus`] (#153) — i.e. it was recovered from a corrupt
5215    /// `config.json` and the recovery hasn't been confirmed — so a corrupt
5216    /// original a user might want to hand-fix is never silently clobbered by
5217    /// an auto-persisted recovery. Call [`Config::confirm_recovery`] (or
5218    /// [`Config::confirm_recovery_and_save_to_dir`]) first.
5219    pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5220        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5221        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5222            anyhow::bail!(
5223                "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5224                 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5225                 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5226                status.source,
5227                status.quarantine_path,
5228            );
5229        }
5230        if self.proxy_auth_credential_ref.is_none()
5231            && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5232        {
5233            anyhow::bail!(
5234                "proxy auth requires the isolated credential transaction before persistence"
5235            );
5236        }
5237        if self.env_vars.iter().any(|entry| {
5238            entry.secret
5239                && entry.credential_ref.is_none()
5240                && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5241        }) {
5242            anyhow::bail!(
5243                "secret env vars require the isolated credential transaction before persistence"
5244            );
5245        }
5246        if [
5247            (
5248                &self.notifications.ntfy.token,
5249                &self.notifications.ntfy.token_encrypted,
5250                &self.notifications.ntfy.credential_ref,
5251            ),
5252            (
5253                &self.notifications.bark.device_key,
5254                &self.notifications.bark.device_key_encrypted,
5255                &self.notifications.bark.credential_ref,
5256            ),
5257        ]
5258        .iter()
5259        .any(|(plaintext, ciphertext, reference)| {
5260            reference.is_none()
5261                && (plaintext
5262                    .as_deref()
5263                    .is_some_and(|value| !value.trim().is_empty())
5264                    || ciphertext.is_some())
5265        }) {
5266            anyhow::bail!(
5267                "notification secrets require the isolated credential transaction before persistence"
5268            );
5269        }
5270
5271        if crate::modular_authority_boundary_present(&data_dir)
5272            .context("Failed to inspect modular configuration authority boundary")?
5273        {
5274            crate::ConfigFacade::open_or_migrate(&data_dir)
5275                .context("Failed to recover modular configuration before persistence")?;
5276            crate::persist_facade_effective_config(&data_dir, self)
5277                .context("Failed to persist modular configuration sections")?;
5278            return Ok(());
5279        }
5280
5281        if crate::section_layout_is_active(&data_dir)
5282            .context("Failed to inspect modular configuration layout")?
5283        {
5284            crate::persist_facade_effective_config(&data_dir, self)
5285                .context("Failed to persist modular configuration sections")?;
5286            return Ok(());
5287        }
5288
5289        let path = data_dir.join("config.json");
5290
5291        if let Some(parent) = path.parent() {
5292            std::fs::create_dir_all(parent)
5293                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5294        }
5295
5296        let mut to_save = self.clone();
5297        to_save.clear_legacy_provider_aliases_for_instance_mode();
5298        // Never persist `data_dir` into config.json (data dir is runtime-derived).
5299        to_save.extra.remove("data_dir");
5300        // Root-level `model` is deprecated; do not persist it.
5301        to_save.extra.remove("model");
5302        // `subagents.broker` is `#[serde(skip)]` (runtime-only, lives in its own
5303        // broker.json / embedded in-process) — nothing to encrypt or persist here.
5304        to_save.refresh_encrypted_secrets()?;
5305        to_save.ensure_provider_instance_credentials_isolated()?;
5306        to_save.sanitize_mcp_credential_refs_for_disk();
5307        to_save.sanitize_env_vars_for_disk();
5308        to_save.sanitize_notifications_for_disk();
5309        to_save.sanitize_cluster_fabric_for_disk();
5310        to_save.assign_connect_platform_ids();
5311        to_save.normalize_tool_settings();
5312        to_save.normalize_skill_settings();
5313
5314        // Split `connect` (#455) out of the config.json document: bamboo-connect
5315        // platform-bridge credentials (bot tokens, allowlists) get their own
5316        // sibling file, connect.json (written below), instead of living in
5317        // config.json — different sensitivity/lifecycle. The `connect` FIELD on
5318        // `Config` keeps its normal serde shape unchanged (still required by the
5319        // settings API / `preserve_masked_connect_secrets`, which operate on the
5320        // in-memory struct) — only the serialized DOCUMENT that becomes
5321        // config.json's bytes has the key stripped, and that's done on the
5322        // `serde_json::Value` here, not via `#[serde(skip)]` on the field.
5323        let mut config_value = durable_root_value(to_save.values.clone())
5324            .context("Failed to serialize root config DTO to JSON")?;
5325        if let Some(obj) = config_value.as_object_mut() {
5326            obj.remove("connect");
5327        }
5328        let content = serde_json::to_string_pretty(&config_value)
5329            .context("Failed to serialize config to JSON")?;
5330
5331        // Persist extracted modules before stripping their legacy inline
5332        // representation from config.json. If the process crashes or the root
5333        // rewrite fails during the first migration, the next load can still use
5334        // either the new sidecars or the untouched inline values. Do this before
5335        // rotating root backups so a sidecar error cannot consume backup history
5336        // for a root document that was never rewritten.
5337        to_save.memory.save_sync(&data_dir)?;
5338        to_save.subagents.save_sync(&data_dir)?;
5339        to_save.providers.save_sync(&data_dir)?;
5340
5341        // Back up the current on-disk config (last-known-good) before overwriting,
5342        // so corruption (a bad/partial write, external edit, disk issue) stays
5343        // recoverable via config.json.bak on the next load. Best-effort. Only
5344        // refresh the backup from a PARSEABLE config.json — otherwise a save right
5345        // after an in-memory recovery (where the on-disk config.json is still the
5346        // corrupt original) would clobber the good .bak with garbage. #37.
5347        if path.exists()
5348            && std::fs::read_to_string(&path)
5349                .ok()
5350                .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5351        {
5352            // Rotate the older generations down (.bak -> .bak.1 -> .bak.2 …) so a
5353            // few last-known-good snapshots survive, then snapshot the current
5354            // (parseable) config.json as the freshest .bak. #135.
5355            rotate_backups(&path, BAK_GENERATIONS);
5356            let backup = backup_path_for(&path, 0);
5357            let backup_result = std::fs::read(&path).and_then(|bytes| {
5358                let mut value: Value = serde_json::from_slice(&bytes)
5359                    .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5360                if sanitize_ref_backed_mcp_json(&mut value) {
5361                    let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5362                        std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5363                    })?;
5364                    write_atomic(&backup, &sanitized)
5365                } else {
5366                    std::fs::copy(&path, &backup).map(|_| ())
5367                }
5368            });
5369            if let Err(e) = backup_result {
5370                tracing::warn!("Failed to back up config.json before save: {}", e);
5371            }
5372            scrub_ref_backed_mcp_from_config_backups(&path);
5373        }
5374
5375        write_atomic(&path, content.as_bytes())
5376            .with_context(|| format!("Failed to write config file: {:?}", path))?;
5377
5378        save_connect_config(&to_save.connect, &data_dir)?;
5379
5380        Ok(())
5381    }
5382}
5383
5384/// Remove credential-ref-backed MCP values from a raw root document without
5385/// otherwise normalizing its compatibility shape. This is used for rotated
5386/// root backups as well as the typed disk DTO so a pre-fix root cannot keep a
5387/// duplicate secret alive for several more saves.
5388fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5389    let Some(object) = root.as_object_mut() else {
5390        return false;
5391    };
5392    let Some(mcp) = (if object.contains_key("mcpServers") {
5393        object.get_mut("mcpServers")
5394    } else {
5395        object.get_mut("mcp")
5396    }) else {
5397        return false;
5398    };
5399    let mut changed = false;
5400    if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5401        for server in servers {
5402            if let Some(object) = server.as_object_mut() {
5403                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5404                {
5405                    changed |= sanitize_ref_backed_mcp_transport(transport);
5406                }
5407            }
5408        }
5409    } else if let Some(servers) = mcp.as_object_mut() {
5410        for server in servers.values_mut() {
5411            if let Some(object) = server.as_object_mut() {
5412                changed |= sanitize_ref_backed_mcp_transport(object);
5413                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5414                {
5415                    changed |= sanitize_ref_backed_mcp_transport(transport);
5416                }
5417            }
5418        }
5419    }
5420    changed
5421}
5422
5423fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5424    let mut changed = false;
5425    let env_names = object
5426        .get("env_credential_refs")
5427        .and_then(Value::as_object)
5428        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5429        .unwrap_or_default();
5430    for name in env_names {
5431        for field in ["env", "env_encrypted"] {
5432            if object
5433                .get_mut(field)
5434                .and_then(Value::as_object_mut)
5435                .and_then(|values| values.remove(&name))
5436                .is_some()
5437            {
5438                changed = true;
5439            }
5440        }
5441    }
5442    let header_names = object
5443        .get("header_credential_refs")
5444        .and_then(Value::as_object)
5445        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5446        .unwrap_or_default();
5447    for name in header_names {
5448        for field in ["headers", "headers_encrypted"] {
5449            if object
5450                .get_mut(field)
5451                .and_then(Value::as_object_mut)
5452                .and_then(|values| values.remove(&name))
5453                .is_some()
5454            {
5455                changed = true;
5456            }
5457        }
5458    }
5459    if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5460        for header in headers {
5461            let Some(header) = header.as_object_mut() else {
5462                continue;
5463            };
5464            if header.get("credential_ref").is_some_and(Value::is_string) {
5465                changed |= header.remove("value").is_some();
5466                changed |= header.remove("value_encrypted").is_some();
5467            }
5468        }
5469    }
5470    changed
5471}
5472
5473fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5474    for generation in 0..BAK_GENERATIONS {
5475        let backup = backup_path_for(path, generation);
5476        let Ok(bytes) = std::fs::read(&backup) else {
5477            continue;
5478        };
5479        let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5480            continue;
5481        };
5482        if sanitize_ref_backed_mcp_json(&mut value) {
5483            if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5484                if let Err(error) = write_atomic(&backup, &sanitized) {
5485                    tracing::warn!(
5486                        "Failed to scrub MCP credentials from {:?}: {}",
5487                        backup,
5488                        error
5489                    );
5490                }
5491            }
5492        }
5493    }
5494}
5495
5496/// Persist `connect` (#455) to its own sibling file, `connect.json`, next to
5497/// config.json — the save-side counterpart of [`Config::merge_connect_config`].
5498///
5499/// Only writes when the config is non-empty OR the file already exists, so a
5500/// fresh/default install with no platforms configured never gets a
5501/// `connect.json` littering its data dir. Before an existing file is
5502/// overwritten, it's copied aside to a single `connect.json.bak` generation
5503/// (best-effort) — connect.json doesn't need config.json's multi-generation
5504/// rotation, one last-known-good snapshot is enough.
5505fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5506    let path = data_dir.join("connect.json");
5507    if connect_config_is_empty(connect) && !path.exists() {
5508        return Ok(());
5509    }
5510
5511    if path.exists() {
5512        let backup = path.with_extension("json.bak");
5513        if let Err(e) = std::fs::copy(&path, &backup) {
5514            tracing::warn!("Failed to back up connect.json before save: {}", e);
5515        }
5516    }
5517
5518    let content = serde_json::to_string_pretty(connect)
5519        .context("Failed to serialize connect config to JSON")?;
5520    write_atomic(&path, content.as_bytes())
5521        .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5522    Ok(())
5523}
5524
5525/// Remove the legacy inline `connect` key from `config.json` on disk, if
5526/// present — the narrow, load-side counterpart of the full-document rewrite
5527/// [`Config::save_to_dir`] would otherwise perform just to drop one stale
5528/// key. Used by [`Config::merge_connect_config`] both when adopting a
5529/// pure-legacy `connect` key (migration) and when a stale legacy key lingers
5530/// alongside an authoritative connect.json. #457.
5531///
5532/// Operates on the raw `serde_json::Value` read straight from disk — NOT on
5533/// the typed `Config` — so it touches nothing but the one key: no other
5534/// secret gets re-encrypted, and no `config.json.bak` generation gets
5535/// rotated, as a side effect of a load.
5536///
5537/// Best-effort: read/parse/write failures are logged, not propagated — this
5538/// runs as a side effect of `Config::new()` / load, which has no `Result` to
5539/// surface it through. A failure here just leaves the stale key in place
5540/// until the next natural save; connect.json (written separately) is already
5541/// authoritative in memory either way.
5542fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5543    let config_path = data_dir.join("config.json");
5544    let content = match std::fs::read_to_string(&config_path) {
5545        Ok(content) => content,
5546        Err(e) => {
5547            tracing::error!(
5548                "Failed to read config.json to strip legacy `connect` key: {}",
5549                e
5550            );
5551            return;
5552        }
5553    };
5554    let mut value: serde_json::Value = match serde_json::from_str(&content) {
5555        Ok(value) => value,
5556        Err(e) => {
5557            tracing::error!(
5558                "Failed to parse config.json to strip legacy `connect` key: {}",
5559                e
5560            );
5561            return;
5562        }
5563    };
5564    let Some(obj) = value.as_object_mut() else {
5565        return;
5566    };
5567    if obj.remove("connect").is_none() {
5568        // Nothing to strip (e.g. raced with a concurrent save that already
5569        // dropped it) — avoid an unnecessary rewrite.
5570        return;
5571    }
5572    let rewritten = match serde_json::to_string_pretty(&value) {
5573        Ok(rewritten) => rewritten,
5574        Err(e) => {
5575            tracing::error!(
5576                "Failed to serialize config.json after stripping legacy `connect` key: {}",
5577                e
5578            );
5579            return;
5580        }
5581    };
5582    if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5583        tracing::error!(
5584            "Failed to write config.json after stripping legacy `connect` key: {}",
5585            e
5586        );
5587    }
5588}
5589
5590/// Sweep the rotated `config.json.bak[.N]` generations for a legacy embedded
5591/// `connect` sub-tree that predates the #455 connect.json split, and strip it
5592/// in place. #468 (follow-up to #457).
5593///
5594/// `strip_legacy_connect_key_from_config_json` only ever rewrites the CURRENT
5595/// `config.json` — it never reaches into `.bak` generations, and the normal
5596/// backup-rotation path (see [`rotate_backups`]) only overwrites a `.bak[.N]`
5597/// slot as a side effect of a fresh SAVE. An instance that upgraded from a
5598/// pre-#455 build but rarely (or never) triggers a config save can therefore
5599/// carry the legacy, encrypted `connect` sub-tree — including bot tokens, an
5600/// immediately-usable remote-control credential — in an old backup generation
5601/// indefinitely, even after its live config.json has long since been
5602/// migrated.
5603///
5604/// Deliberately surgical, mirroring the `.bak` files' role as the user's
5605/// recovery net (#493's "backups are a low-sensitivity snapshot, don't fuss
5606/// with them" posture):
5607/// - a generation that doesn't exist, or that fails to parse as JSON, is
5608///   SKIPPED — logged, never deleted, never guessed at. Corrupt/foreign
5609///   content in a `.bak` slot is left exactly as found for hand inspection.
5610/// - a generation that parses but carries no `connect` key (the overwhelming
5611///   majority, especially on any instance that predates this fix by more
5612///   than `BAK_GENERATIONS` saves) is left COMPLETELY untouched — not even a
5613///   byte-identical rewrite — so its mtime and on-disk bytes survive.
5614/// - only a generation that actually parses AND carries the legacy key gets
5615///   rewritten, via the same key-removal-on-the-raw-`Value` + `write_atomic`
5616///   approach as `strip_legacy_connect_key_from_config_json`, so every other
5617///   byte of that snapshot (all other settings, formatting aside) survives.
5618///
5619/// Runs unconditionally on every load (not gated on the CURRENT config.json
5620/// still carrying the legacy key) specifically to catch already-migrated
5621/// installs whose backups predate this fix. Cheap: at most `BAK_GENERATIONS`
5622/// small file reads, and a genuine no-op (zero writes) once every generation
5623/// has been swept once. Best-effort like its sibling: failures are logged,
5624/// not propagated, since this runs as a side effect of `Config::new()` /
5625/// load, which has no `Result` to surface it through.
5626fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5627    let config_path = data_dir.join("config.json");
5628    for gen in 0..BAK_GENERATIONS {
5629        let backup = backup_path_for(&config_path, gen);
5630        let content = match std::fs::read_to_string(&backup) {
5631            Ok(content) => content,
5632            Err(e) => {
5633                if e.kind() != std::io::ErrorKind::NotFound {
5634                    tracing::warn!(
5635                        "Failed to read {:?} while scanning for legacy connect data ({}); \
5636                         leaving it untouched",
5637                        backup,
5638                        e
5639                    );
5640                }
5641                continue;
5642            }
5643        };
5644        let mut value: serde_json::Value = match serde_json::from_str(&content) {
5645            Ok(value) => value,
5646            Err(e) => {
5647                tracing::warn!(
5648                    "Skipping unparsable backup {:?} while scanning for legacy connect data \
5649                     ({}); left untouched (never deleted)",
5650                    backup,
5651                    e
5652                );
5653                continue;
5654            }
5655        };
5656        let Some(obj) = value.as_object_mut() else {
5657            // Not a JSON object (e.g. `null`/an array) — nothing to strip, and
5658            // not a shape we should try to rewrite. Leave it alone.
5659            continue;
5660        };
5661        if obj.remove("connect").is_none() {
5662            // No legacy key in this generation — skip without writing so the
5663            // file's bytes/mtime are left completely untouched.
5664            continue;
5665        }
5666        let rewritten = match serde_json::to_string_pretty(&value) {
5667            Ok(rewritten) => rewritten,
5668            Err(e) => {
5669                tracing::error!(
5670                    "Failed to serialize {:?} after stripping legacy connect data: {}",
5671                    backup,
5672                    e
5673                );
5674                continue;
5675            }
5676        };
5677        match write_atomic(&backup, rewritten.as_bytes()) {
5678            Ok(()) => tracing::info!(
5679                "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5680                backup
5681            ),
5682            Err(e) => tracing::error!(
5683                "Failed to write {:?} after stripping legacy connect data: {}",
5684                backup,
5685                e
5686            ),
5687        }
5688    }
5689}
5690
5691/// Quarantine an unparsable `connect.json` to a single `connect.json.bak`
5692/// generation (best-effort) so the bad content survives for inspection
5693/// instead of being silently discarded. Unlike config.json's timestamped,
5694/// N-generation quarantine, connect.json only needs one slot — it's a much
5695/// smaller, less complex document and this is a fail-SAFE (empty/inert
5696/// bridge), not a fail-recover, posture. #455.
5697///
5698/// MOVES the corrupt file rather than copying it (#457): a copy would leave
5699/// the same corrupt `connect.json` sitting in the data dir right next to its
5700/// own quarantine copy, which reads as confusing/ambiguous mid-incident
5701/// (which one is live?). `rename` is used first (atomic, no partial-copy
5702/// window); if that fails — e.g. `connect.json.bak` and the data dir are on
5703/// different filesystems — fall back to copy-then-remove so the corrupt
5704/// original still doesn't linger.
5705fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5706    let backup = connect_path.with_extension("json.bak");
5707    match std::fs::rename(connect_path, &backup) {
5708        Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5709        Err(e) => {
5710            tracing::warn!(
5711                "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5712                backup,
5713                e
5714            );
5715            if let Err(e) = std::fs::copy(connect_path, &backup) {
5716                tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5717                return;
5718            }
5719            if let Err(e) = std::fs::remove_file(connect_path) {
5720                tracing::error!(
5721                    "Quarantined corrupt connect.json to {:?} but failed to remove the \
5722                     original {:?}: {}",
5723                    backup,
5724                    connect_path,
5725                    e
5726                );
5727            }
5728        }
5729    }
5730}
5731
5732/// How many `config.json.corrupted.*` quarantine files to keep. Each corrupt load
5733/// drops one; without a cap they accumulate unbounded. Newest `N` are retained.
5734const QUARANTINE_KEEP: usize = 5;
5735
5736/// Copy a corrupt config file aside to `config.json.corrupted.<nanos>` so the
5737/// user's (unparseable) configuration is preserved for inspection/recovery
5738/// instead of being silently discarded and then overwritten by defaults. #37.
5739///
5740/// Returns the quarantine path on success, so the caller can attach it to a
5741/// [`ConfigRecoveryStatus`] (#153); `None` if even the copy failed (the
5742/// corrupt original is still left in place at `config_path` regardless, since
5743/// this only ever copies, never moves/deletes).
5744fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5745    let nanos = std::time::SystemTime::now()
5746        .duration_since(std::time::UNIX_EPOCH)
5747        .map(|d| d.as_nanos())
5748        .unwrap_or(0);
5749    // Two corrupt loads in the same nanosecond would land on the same name and the
5750    // second `copy` would silently overwrite the first. Append a counter on
5751    // collision so each quarantine is preserved distinctly. #135.
5752    let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5753    let mut dedup = 1u32;
5754    while quarantine.exists() {
5755        quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5756        dedup += 1;
5757    }
5758    let result = match std::fs::copy(config_path, &quarantine) {
5759        Ok(_) => {
5760            tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5761            Some(quarantine)
5762        }
5763        Err(e) => {
5764            tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5765            None
5766        }
5767    };
5768    prune_quarantine_files(config_path, QUARANTINE_KEEP);
5769    result
5770}
5771
5772/// Keep only the newest `keep` `config.json.corrupted.*` files next to
5773/// `config_path`, deleting older ones so quarantines don't grow unbounded. #135.
5774fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5775    let Some(dir) = config_path.parent() else {
5776        return;
5777    };
5778    let prefix = "config.json.corrupted.";
5779    let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5780        Ok(entries) => entries
5781            .filter_map(|e| e.ok())
5782            .map(|e| e.path())
5783            .filter(|p| {
5784                p.file_name()
5785                    .and_then(|n| n.to_str())
5786                    .is_some_and(|n| n.starts_with(prefix))
5787            })
5788            .collect(),
5789        Err(_) => return,
5790    };
5791    if quarantines.len() <= keep {
5792        return;
5793    }
5794    // Oldest first (by mtime; missing mtime sorts oldest so it's pruned first).
5795    quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5796    let remove = quarantines.len() - keep;
5797    for stale in quarantines.into_iter().take(remove) {
5798        if let Err(e) = std::fs::remove_file(&stale) {
5799            tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5800        }
5801    }
5802}
5803
5804/// Number of `config.json.bak[.N]` generations to retain (`.bak` + `N-1` numbered).
5805/// More generations = more recovery points if a fresher backup is itself bad. #135.
5806const BAK_GENERATIONS: usize = 3;
5807
5808/// The on-disk path of backup generation `gen` (0 == `config.json.bak`).
5809fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5810    if gen == 0 {
5811        config_path.with_extension("json.bak")
5812    } else {
5813        config_path.with_extension(format!("json.bak.{gen}"))
5814    }
5815}
5816
5817/// Shift the backup generations down before a fresh `.bak` is written:
5818/// `.bak.(N-2) -> .bak.(N-1)`, …, `.bak -> .bak.1`. The oldest is overwritten by
5819/// the shift; the caller then writes the new `.bak`. Walks the highest (oldest)
5820/// destination slot first so no rename clobbers a slot a later move still needs to
5821/// read. Best-effort. #135.
5822fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5823    for gen in (1..generations).rev() {
5824        let from = backup_path_for(config_path, gen - 1);
5825        let to = backup_path_for(config_path, gen);
5826        if from.exists() {
5827            if let Err(e) = std::fs::rename(&from, &to) {
5828                tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5829            }
5830        }
5831    }
5832}
5833
5834pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5835    crate::config_store::AtomicFileStore::new(path)
5836        .write_bytes_without_backup(content)
5837        .map_err(|error| match error {
5838            crate::config_store::ConfigStoreError::Io(error) => error,
5839            other => std::io::Error::other(other),
5840        })
5841}
5842
5843#[cfg(test)]
5844mod tests {
5845    use super::*;
5846    use std::ffi::OsString;
5847    use std::path::PathBuf;
5848    use std::sync::Mutex;
5849    use std::time::{SystemTime, UNIX_EPOCH};
5850
5851    #[test]
5852    fn tools_config_preserves_unknown_keys_across_roundtrip() {
5853        let input = serde_json::json!({
5854            "disabled": ["bash"],
5855            "plugin_runtime": {
5856                "timeout_ms": 5_000,
5857                "sandbox": true
5858            },
5859            "future_flag": "enabled"
5860        });
5861
5862        let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5863        assert_eq!(config.disabled, vec!["bash"]);
5864        assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5865        assert_eq!(config.extra["future_flag"], "enabled");
5866        assert_eq!(serde_json::to_value(config).unwrap(), input);
5867    }
5868
5869    #[test]
5870    fn tools_config_keeps_section_when_only_unknown_keys_present() {
5871        let input = serde_json::json!({
5872            "tool_extension": {
5873                "mode": "strict",
5874                "options": ["one", "two"]
5875            }
5876        });
5877        let config: Config = serde_json::from_value(serde_json::json!({
5878            "tools": input.clone()
5879        }))
5880        .unwrap();
5881
5882        assert!(config.tools.disabled.is_empty());
5883        assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5884        assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5885
5886        let temp_home = TempHome::new();
5887        config.save_to_dir(temp_home.path.clone()).unwrap();
5888        let persisted: Value =
5889            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5890                .unwrap();
5891        assert_eq!(persisted["tools"], input);
5892    }
5893
5894    #[test]
5895    fn skills_config_preserves_unknown_keys_across_roundtrip() {
5896        let input = serde_json::json!({
5897            "external_catalog": {
5898                "path": "/opt/bamboo/skills",
5899                "refresh": false
5900            },
5901            "schema_version": 2
5902        });
5903
5904        let config: Config = serde_json::from_value(serde_json::json!({
5905            "skills": input.clone()
5906        }))
5907        .unwrap();
5908        assert!(config.skills.disabled.is_empty());
5909        assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5910        assert_eq!(config.skills.extra["schema_version"], 2);
5911        assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5912
5913        let temp_home = TempHome::new();
5914        config.save_to_dir(temp_home.path.clone()).unwrap();
5915        let persisted: Value =
5916            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5917                .unwrap();
5918        assert_eq!(persisted["skills"], input);
5919    }
5920
5921    #[test]
5922    fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5923        let config: Config = serde_json::from_value(serde_json::json!({
5924            "hooks": {
5925                "image_fallback": {"enabled": true, "mode": "placeholder"}
5926            },
5927            "lifecycle_hooks": {
5928                "enabled": true,
5929                "PreToolUse": [{
5930                    "matcher": "bash|write_file",
5931                    "hooks": [{"type": "command", "command": "guard.sh"}]
5932                }],
5933                "SessionStart": [{
5934                    "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5935                }]
5936            }
5937        }))
5938        .expect("lifecycle hook config should deserialize");
5939
5940        assert!(config.lifecycle_hooks.enabled);
5941        assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5942        assert!(
5943            config.lifecycle_hooks.pre_tool_use[0].enabled,
5944            "legacy groups without an enabled flag remain active"
5945        );
5946        assert_eq!(
5947            config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5948            DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5949        );
5950        assert_eq!(
5951            config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5952            25
5953        );
5954
5955        let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5956        assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5957        assert_eq!(
5958            json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5959            "bash|write_file"
5960        );
5961        assert_eq!(
5962            json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5963            "command"
5964        );
5965        assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5966            .get("enabled")
5967            .is_none());
5968        assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5969            .get("timeout_ms")
5970            .is_none());
5971        assert!(json.get("hooks").is_some());
5972    }
5973
5974    #[test]
5975    fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5976        let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5977            "type": "script",
5978            "path": ".bamboo/hooks/check.js"
5979        }))
5980        .expect("script lifecycle hook should deserialize");
5981
5982        assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5983        assert!(matches!(
5984            handler,
5985            LifecycleHookHandler::Script {
5986                runner: LifecycleScriptRunner::Auto,
5987                ..
5988            }
5989        ));
5990        let json = serde_json::to_value(handler).expect("script hook should serialize");
5991        assert_eq!(json["type"], "script");
5992        assert!(json.get("timeout_ms").is_none());
5993        assert!(json.get("runner").is_none());
5994    }
5995
5996    #[test]
5997    fn script_runner_support_is_extension_aware() {
5998        assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5999        assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
6000        assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
6001        assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
6002        assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
6003        assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
6004        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
6005        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
6006        assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
6007        assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
6008    }
6009
6010    #[test]
6011    fn script_runner_names_round_trip_through_config_json() {
6012        for (runner, name) in [
6013            (LifecycleScriptRunner::Auto, "auto"),
6014            (LifecycleScriptRunner::Node, "node"),
6015            (LifecycleScriptRunner::Bun, "bun"),
6016            (LifecycleScriptRunner::Python, "python"),
6017            (LifecycleScriptRunner::Bash, "bash"),
6018            (LifecycleScriptRunner::PowerShell, "powershell"),
6019            (LifecycleScriptRunner::Cmd, "cmd"),
6020        ] {
6021            let json = serde_json::to_value(runner).unwrap();
6022            assert_eq!(json, name);
6023            assert_eq!(
6024                serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
6025                runner
6026            );
6027        }
6028    }
6029
6030    #[test]
6031    fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
6032        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6033        assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
6034        let json = serde_json::to_value(&config).unwrap();
6035        assert!(json.get("lifecycle_hooks").is_none());
6036    }
6037
6038    #[test]
6039    fn stream_timeout_defaults_are_safe_and_back_compatible() {
6040        let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6041            .expect("legacy config without stream_timeout should load");
6042        let values = ConfigValues::from(root);
6043
6044        assert_eq!(
6045            values.stream_timeout,
6046            StreamTimeoutConfig {
6047                transport_idle_timeout_secs: 120,
6048                first_semantic_timeout_secs: 600,
6049                semantic_idle_timeout_secs: 600,
6050            }
6051        );
6052        values
6053            .stream_timeout
6054            .validate()
6055            .expect("defaults are valid");
6056    }
6057
6058    #[test]
6059    fn llm_retry_defaults_load_from_a_config_without_the_key_and_tolerate_slow_routes() {
6060        let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6061            .expect("legacy config without llm_retry should load");
6062        let values = ConfigValues::from(root);
6063        // Unset means "never configured": the environment-derived policy keeps
6064        // applying, which is what an existing deployment expects on upgrade.
6065        assert_eq!(values.llm_retry, None);
6066
6067        let defaults = LlmRetryConfig::default();
6068        assert_eq!(defaults.max_attempts, 8);
6069        assert_eq!(defaults.attempt_timeout_ms, 180_000);
6070        assert_eq!(defaults.total_budget_ms, 900_000);
6071        defaults.validate().expect("defaults are valid");
6072    }
6073
6074    #[test]
6075    fn llm_retry_rejects_out_of_range_or_inconsistent_policies() {
6076        let valid = LlmRetryConfig::default();
6077        assert!(valid.validate().is_ok());
6078        for broken in [
6079            LlmRetryConfig {
6080                max_attempts: 0,
6081                ..valid
6082            },
6083            LlmRetryConfig {
6084                max_attempts: MAX_LLM_RETRY_ATTEMPTS + 1,
6085                ..valid
6086            },
6087            LlmRetryConfig {
6088                attempt_timeout_ms: 999,
6089                ..valid
6090            },
6091            LlmRetryConfig {
6092                attempt_timeout_ms: MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS + 1,
6093                ..valid
6094            },
6095            LlmRetryConfig {
6096                total_budget_ms: 0,
6097                ..valid
6098            },
6099            LlmRetryConfig {
6100                total_budget_ms: MAX_LLM_RETRY_TOTAL_BUDGET_MS + 1,
6101                ..valid
6102            },
6103            // A base delay above the cap would never be honoured.
6104            LlmRetryConfig {
6105                base_delay_ms: 2_000,
6106                max_delay_ms: 1_000,
6107                ..valid
6108            },
6109        ] {
6110            assert!(broken.validate().is_err(), "{broken:?} must be rejected");
6111        }
6112    }
6113
6114    #[test]
6115    fn llm_retry_round_trips_through_its_section() {
6116        let policy = LlmRetryConfig {
6117            max_attempts: 12,
6118            attempt_timeout_ms: 300_000,
6119            total_budget_ms: 3_600_000,
6120            base_delay_ms: 250,
6121            max_delay_ms: 60_000,
6122        };
6123        let values = ConfigValues {
6124            llm_retry: Some(policy),
6125            ..ConfigValues::default()
6126        };
6127        let json = serde_json::to_value(&values).expect("serialize");
6128        assert_eq!(json["llm_retry"]["max_attempts"], 12);
6129        let round_tripped: ConfigValues = serde_json::from_value(json).expect("deserialize");
6130        assert_eq!(round_tripped.llm_retry, Some(policy));
6131    }
6132
6133    #[test]
6134    fn stream_timeout_round_trips_through_persistence_dto() {
6135        let values = ConfigValues {
6136            stream_timeout: StreamTimeoutConfig {
6137                transport_idle_timeout_secs: 45,
6138                first_semantic_timeout_secs: 900,
6139                semantic_idle_timeout_secs: 300,
6140            },
6141            ..ConfigValues::default()
6142        };
6143
6144        let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
6145        assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
6146        assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
6147        assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
6148
6149        let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
6150        assert_eq!(
6151            ConfigValues::from(root).stream_timeout,
6152            StreamTimeoutConfig {
6153                transport_idle_timeout_secs: 45,
6154                first_semantic_timeout_secs: 900,
6155                semantic_idle_timeout_secs: 300,
6156            }
6157        );
6158    }
6159
6160    #[test]
6161    fn context_management_defaults_to_summary_and_is_omitted() {
6162        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6163        assert_eq!(
6164            config.context_management.strategy,
6165            ContextManagementStrategy::Summary
6166        );
6167        assert_eq!(
6168            config
6169                .context_management
6170                .retrieval_window
6171                .min_recent_user_turns,
6172            3
6173        );
6174        assert_eq!(
6175            config.context_management.retrieval_target_usage_percent(),
6176            25
6177        );
6178        let json = serde_json::to_value(&config).unwrap();
6179        assert!(json.get("context_management").is_none());
6180    }
6181
6182    #[test]
6183    fn retrieval_window_context_management_round_trips() {
6184        let json = serde_json::json!({
6185            "context_management": {
6186                "strategy": "retrieval_window",
6187                "retrieval_window": {
6188                    "min_recent_user_turns": 4,
6189                    "trigger_usage_ratio": 0.82,
6190                    "target_usage_ratio": 0.61,
6191                    "history_tool_required": true,
6192                    "fallback_strategy": "summary"
6193                }
6194            }
6195        });
6196        let root: ConfigRoot = serde_json::from_value(json).unwrap();
6197        let values = ConfigValues::from(root);
6198        assert_eq!(
6199            values.context_management.strategy,
6200            ContextManagementStrategy::RetrievalWindow
6201        );
6202        assert_eq!(
6203            values.context_management.retrieval_window.fallback_strategy,
6204            ContextManagementFallbackStrategy::Summary
6205        );
6206
6207        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6208        assert_eq!(
6209            persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
6210            4
6211        );
6212        assert_eq!(
6213            persisted["context_management"]["strategy"],
6214            "retrieval_window"
6215        );
6216    }
6217
6218    #[test]
6219    fn message_summary_context_management_round_trips_without_changing_defaults() {
6220        let root: ConfigRoot = serde_json::from_value(serde_json::json!({
6221            "context_management": {
6222                "strategy": "message_summary"
6223            }
6224        }))
6225        .unwrap();
6226        let values = ConfigValues::from(root);
6227        assert_eq!(
6228            values.context_management.strategy,
6229            ContextManagementStrategy::MessageSummary
6230        );
6231        assert_eq!(
6232            values.context_management.retrieval_window,
6233            RetrievalWindowContextConfig::default()
6234        );
6235
6236        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6237        assert_eq!(
6238            persisted["context_management"]["strategy"],
6239            "message_summary"
6240        );
6241        let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
6242        assert_eq!(
6243            ConfigValues::from(reloaded).context_management.strategy,
6244            ContextManagementStrategy::MessageSummary
6245        );
6246        assert_eq!(
6247            ContextManagementConfig::default().strategy,
6248            ContextManagementStrategy::Summary
6249        );
6250    }
6251
6252    #[test]
6253    fn context_management_uses_one_explicit_policy_for_every_strategy() {
6254        for strategy in ["summary", "message_summary", "retrieval_window"] {
6255            let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6256                "strategy": strategy,
6257                "trigger_usage_ratio": 0.72,
6258                "target_usage_ratio": 0.20,
6259                "retrieval_window": {"trigger_usage_ratio": 0.9, "target_usage_ratio": 0.6}
6260            }))
6261            .unwrap();
6262            let budget =
6263                policy.apply_compression_policy(bamboo_domain::TokenBudget::for_model(200_000));
6264            assert_eq!(budget.compression_trigger_percent, 72);
6265            assert_eq!(budget.compression_target_percent, 20);
6266            let saved = serde_json::to_value(&policy).unwrap();
6267            let restored: ContextManagementConfig = serde_json::from_value(saved).unwrap();
6268            assert_eq!(restored, policy);
6269        }
6270    }
6271
6272    #[test]
6273    fn context_management_migrates_stricter_legacy_ratios_without_preserving_old_sixty_percent_target(
6274    ) {
6275        for strategy in ["summary", "message_summary", "retrieval_window"] {
6276            for (trigger, target, expected_trigger, expected_target) in
6277                [(0.80, 0.60, 0.80, 0.25), (0.65, 0.15, 0.65, 0.15)]
6278            {
6279                let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6280                    "strategy": strategy,
6281                    "retrieval_window": {"trigger_usage_ratio": trigger, "target_usage_ratio": target}
6282                })).unwrap();
6283                assert_eq!(policy.trigger_usage_ratio, expected_trigger);
6284                assert_eq!(policy.target_usage_ratio, expected_target);
6285            }
6286        }
6287    }
6288
6289    #[test]
6290    fn context_management_rejects_unrepresentable_shared_range() {
6291        for (trigger, target) in [(0.80, 0.80), (1.01, 0.25), (0.015, 0.01), (0.80, 0.005)] {
6292            let value =
6293                serde_json::json!({"trigger_usage_ratio": trigger, "target_usage_ratio": target});
6294            assert!(serde_json::from_value::<ContextManagementConfig>(value).is_err());
6295        }
6296    }
6297
6298    #[test]
6299    fn retrieval_window_context_management_rejects_unsafe_policy() {
6300        for invalid in [
6301            serde_json::json!({
6302                "strategy": "retrieval_window",
6303                "retrieval_window": {"min_recent_user_turns": 0}
6304            }),
6305            serde_json::json!({
6306                "strategy": "retrieval_window",
6307                "retrieval_window": {
6308                    "target_usage_ratio": 0.8,
6309                    "trigger_usage_ratio": 0.8
6310                }
6311            }),
6312            serde_json::json!({
6313                "strategy": "retrieval_window",
6314                "retrieval_window": {
6315                    "target_usage_ratio": 0.005,
6316                    "trigger_usage_ratio": 0.006
6317                }
6318            }),
6319            serde_json::json!({
6320                "strategy": "retrieval_window",
6321                "retrieval_window": {"history_tool_required": false}
6322            }),
6323        ] {
6324            serde_json::from_value::<ContextManagementConfig>(invalid)
6325                .expect_err("unsafe retrieval-window policy must fail closed");
6326        }
6327    }
6328
6329    #[test]
6330    fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6331        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6332            "provider_type": "openai",
6333            "enabled": true
6334        }))
6335        .unwrap();
6336        let mut config = Config::default();
6337        config.values.provider = "gemini".to_string();
6338        config
6339            .provider_instances
6340            .insert("work".to_string(), instance);
6341        config.default_provider_instance = Some("work".to_string());
6342        config.providers_mut().openai = Some(OpenAIConfig::default());
6343
6344        let json = serde_json::to_value(&config).unwrap();
6345        assert_eq!(json["provider"], "gemini");
6346        assert!(json["providers"]["openai"].is_object());
6347        assert_eq!(json["default_provider_instance"], "work");
6348
6349        let round_trip: Config = serde_json::from_value(json).unwrap();
6350        assert_eq!(round_trip.provider, "gemini");
6351        assert_eq!(
6352            round_trip.default_provider_instance.as_deref(),
6353            Some("work")
6354        );
6355        assert!(round_trip.provider_instances.contains_key("work"));
6356        assert!(round_trip.providers().openai.is_some());
6357    }
6358
6359    #[test]
6360    fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6361        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6362        let dir = tempfile::tempdir().unwrap();
6363        let mut config = Config::default();
6364        config.values.provider = "anthropic".to_string();
6365        config.provider_instances.insert(
6366            "work".to_string(),
6367            serde_json::from_value(serde_json::json!({
6368                "provider_type": "openai",
6369                "model": "gpt-instance",
6370                "enabled": true,
6371                "future_instance_metadata": "preserved"
6372            }))
6373            .unwrap(),
6374        );
6375        config.default_provider_instance = Some("work".to_string());
6376        config.features.provider_model_ref = true;
6377        config.providers_mut().openai = Some(OpenAIConfig::default());
6378        config.providers_mut().anthropic = Some(AnthropicConfig::default());
6379        config
6380            .providers_mut()
6381            .extra
6382            .insert("provider".to_string(), serde_json::json!("anthropic"));
6383        config.providers_mut().extra.insert(
6384            "future_provider".to_string(),
6385            serde_json::json!({"kept": true}),
6386        );
6387
6388        let (root_bytes, provider_bytes) =
6389            config.prepare_provider_transaction_documents(&[]).unwrap();
6390        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6391        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6392        assert!(root.get("provider").is_none());
6393        assert!(root.get("providers").is_none());
6394        assert_eq!(root["default_provider_instance"], "work");
6395        assert_eq!(
6396            root["provider_instances"]["work"]["future_instance_metadata"],
6397            "preserved"
6398        );
6399        assert!(providers.get("provider").is_none());
6400        for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6401            assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6402        }
6403        assert_eq!(providers["future_provider"]["kept"], true);
6404
6405        config.save_to_dir(dir.path().to_path_buf()).unwrap();
6406        let saved_root: Value =
6407            serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6408                .unwrap();
6409        let saved_providers: Value =
6410            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6411                .unwrap();
6412        assert!(saved_root.get("provider").is_none());
6413        assert!(saved_root.get("providers").is_none());
6414        assert_eq!(saved_root["default_provider_instance"], "work");
6415        assert!(saved_providers.get("openai").is_none());
6416        assert!(saved_providers.get("anthropic").is_none());
6417        assert!(saved_providers.get("provider").is_none());
6418        assert_eq!(saved_providers["future_provider"]["kept"], true);
6419
6420        config.providers_mut().gemini = Some(GeminiConfig::default());
6421        config.save_providers_to_dir(dir.path()).unwrap();
6422        let provider_only: Value =
6423            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6424                .unwrap();
6425        assert!(provider_only.get("gemini").is_none());
6426        assert!(provider_only.get("provider").is_none());
6427        assert_eq!(provider_only["future_provider"]["kept"], true);
6428    }
6429
6430    #[test]
6431    fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6432        let mut config = Config::default();
6433        config.values.provider = "openai".to_string();
6434        config.providers_mut().openai = Some(OpenAIConfig {
6435            model: Some("gpt-legacy".to_string()),
6436            ..OpenAIConfig::default()
6437        });
6438        config.provider_instances.insert(
6439            "work".to_string(),
6440            serde_json::from_value(serde_json::json!({
6441                "provider_type": "copilot",
6442                "enabled": true
6443            }))
6444            .unwrap(),
6445        );
6446        config.default_provider_instance = Some("openai".to_string());
6447
6448        let (root_bytes, provider_bytes) =
6449            config.prepare_provider_transaction_documents(&[]).unwrap();
6450        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6451        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6452        assert_eq!(root["provider"], "openai");
6453        assert_eq!(root["default_provider_instance"], "openai");
6454        assert!(root["provider_instances"]["work"].is_object());
6455        assert_eq!(providers["openai"]["model"], "gpt-legacy");
6456    }
6457
6458    #[test]
6459    fn persistence_keeps_legacy_provider_without_instance_default() {
6460        let values = ConfigValues {
6461            provider: "gemini".to_string(),
6462            ..ConfigValues::default()
6463        };
6464
6465        let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6466        assert_eq!(json["provider"], "gemini");
6467    }
6468
6469    #[test]
6470    fn stream_timeout_rejects_zero_and_unbounded_values() {
6471        for (field, invalid) in [
6472            ("transport_idle_timeout_secs", 0),
6473            ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6474            ("semantic_idle_timeout_secs", 0),
6475        ] {
6476            let mut timeout = serde_json::json!({});
6477            timeout[field] = serde_json::json!(invalid);
6478            let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6479                .expect_err("invalid timeout must be rejected");
6480            assert!(error.to_string().contains("stream timeout must be between"));
6481        }
6482    }
6483
6484    struct EnvVarGuard {
6485        key: &'static str,
6486        previous: Option<OsString>,
6487    }
6488
6489    impl EnvVarGuard {
6490        fn set(key: &'static str, value: &str) -> Self {
6491            let previous = std::env::var_os(key);
6492            std::env::set_var(key, value);
6493            Self { key, previous }
6494        }
6495
6496        fn unset(key: &'static str) -> Self {
6497            let previous = std::env::var_os(key);
6498            std::env::remove_var(key);
6499            Self { key, previous }
6500        }
6501    }
6502
6503    impl Drop for EnvVarGuard {
6504        fn drop(&mut self) {
6505            match &self.previous {
6506                Some(value) => std::env::set_var(self.key, value),
6507                None => std::env::remove_var(self.key),
6508            }
6509        }
6510    }
6511
6512    #[test]
6513    fn run_budget_config_merge_is_tighten_only_per_field() {
6514        let config_default = RunBudgetConfig {
6515            max_total_tokens: Some(100_000),
6516            max_tool_calls: Some(500),
6517            max_subagents: Some(10),
6518            max_rounds: Some(200),
6519        };
6520
6521        // No override at all: config default passes through unchanged.
6522        assert_eq!(
6523            config_default.merged_with_override(None),
6524            config_default,
6525            "no override falls back to the config default entirely"
6526        );
6527
6528        // Override TIGHTENS exactly one field; the others keep the config
6529        // default (per-field, not all-or-nothing).
6530        let tighten_one = RunBudgetConfig {
6531            max_total_tokens: Some(5_000),
6532            max_tool_calls: None,
6533            max_subagents: None,
6534            max_rounds: None,
6535        };
6536        let merged = config_default.merged_with_override(Some(&tighten_one));
6537        assert_eq!(merged.max_total_tokens, Some(5_000));
6538        assert_eq!(merged.max_tool_calls, Some(500));
6539        assert_eq!(merged.max_subagents, Some(10));
6540        assert_eq!(merged.max_rounds, Some(200));
6541
6542        // A LOOSER override is clamped to the config default: a client can
6543        // never raise the operator's ceiling (PR #539 review, finding #3).
6544        let loosen_attempt = RunBudgetConfig {
6545            max_total_tokens: Some(999_999_999),
6546            max_tool_calls: Some(10_000),
6547            max_subagents: Some(1_000),
6548            max_rounds: Some(100_000),
6549        };
6550        assert_eq!(
6551            config_default.merged_with_override(Some(&loosen_attempt)),
6552            config_default,
6553            "looser per-request values must be clamped to the config ceiling"
6554        );
6555
6556        // Nor can it REMOVE a configured ceiling by omitting the field: an
6557        // absent override field keeps the config default, it does not mean
6558        // unlimited.
6559        let empty_override = RunBudgetConfig::default();
6560        assert_eq!(
6561            config_default.merged_with_override(Some(&empty_override)),
6562            config_default,
6563            "an all-absent override body keeps every configured ceiling"
6564        );
6565
6566        // An unlimited config default CAN be tightened by the request (the
6567        // request is the only ceiling then), and stays unlimited on fields the
6568        // request does not set.
6569        let unlimited_default = RunBudgetConfig::default();
6570        let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6571        assert_eq!(merged.max_total_tokens, Some(5_000));
6572        assert_eq!(merged.max_tool_calls, None);
6573        assert_eq!(merged.max_subagents, None);
6574        assert_eq!(merged.max_rounds, None);
6575
6576        // The round cap tightens the same way: a request can cap rounds below
6577        // an unlimited config default but never raise a configured ceiling.
6578        let tighten_rounds = RunBudgetConfig {
6579            max_total_tokens: None,
6580            max_tool_calls: None,
6581            max_subagents: None,
6582            max_rounds: Some(20),
6583        };
6584        let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6585        assert_eq!(merged.max_rounds, Some(20));
6586        let merged = config_default.merged_with_override(Some(&tighten_rounds));
6587        assert_eq!(
6588            merged.max_rounds,
6589            Some(20),
6590            "a stricter per-request round cap wins over the config default"
6591        );
6592    }
6593
6594    #[test]
6595    fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6596        assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6597        assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6598        assert_eq!(RunBudgetConfig::default().max_subagents, None);
6599        assert_eq!(RunBudgetConfig::default().max_rounds, None);
6600
6601        let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6602        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6603        assert_eq!(cfg.max_total_tokens, Some(250_000));
6604        assert_eq!(
6605            cfg.max_tool_calls, None,
6606            "absent field defaults to unlimited"
6607        );
6608        assert_eq!(
6609            cfg.max_rounds, None,
6610            "absent round cap defaults to unlimited"
6611        );
6612
6613        assert_eq!(cfg.max_subagents, Some(3));
6614
6615        // The round cap round-trips like the other fields.
6616        let json = r#"{ "max_rounds": 250 }"#;
6617        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6618        assert_eq!(cfg.max_rounds, Some(250));
6619        let round_tripped: RunBudgetConfig =
6620            serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6621        assert_eq!(round_tripped.max_rounds, Some(250));
6622        assert_eq!(round_tripped.max_total_tokens, None);
6623
6624        // Absent fields are omitted on serialize (skip_serializing_if), so an
6625        // all-default config round-trips to `{}` rather than four explicit
6626        // nulls.
6627        let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6628        assert_eq!(empty, "{}");
6629    }
6630
6631    #[test]
6632    fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6633        let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6634        assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6635        assert!(!serde_json::to_string(&old)
6636            .unwrap()
6637            .contains("max_spawn_depth"));
6638        for depth in [0, 1, 8] {
6639            let config: SubagentsConfig =
6640                serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6641            assert_eq!(config.effective_max_spawn_depth(), depth);
6642            assert_eq!(
6643                serde_json::to_value(config).unwrap()["max_spawn_depth"],
6644                depth
6645            );
6646        }
6647        for invalid in [
6648            serde_json::json!(-1),
6649            serde_json::json!(1.5),
6650            serde_json::json!("4"),
6651        ] {
6652            assert!(serde_json::from_value::<SubagentsConfig>(
6653                serde_json::json!({"max_spawn_depth": invalid}),
6654            )
6655            .is_err());
6656        }
6657    }
6658
6659    #[test]
6660    fn subagents_config_without_remote_placements_deserializes_empty() {
6661        // An OLD config (predating P1.5) has no `remote_placements` key — it must
6662        // still deserialize, with an empty placement list (default = local path).
6663        let json = r#"{ "max_concurrent": 4 }"#;
6664        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6665        assert_eq!(cfg.max_concurrent, Some(4));
6666        assert!(cfg.remote_placements.is_empty());
6667        // And an empty placement list is omitted on re-serialize (skip_if empty).
6668        let back = serde_json::to_string(&cfg).unwrap();
6669        assert!(
6670            !back.contains("remote_placements"),
6671            "empty vec is skipped: {back}"
6672        );
6673    }
6674
6675    #[test]
6676    fn remote_actor_placement_round_trips() {
6677        let json = r#"{
6678            "remote_placements": [
6679                {
6680                    "role": "explorer",
6681                    "endpoint": "wss://gpu-host:8443",
6682                    "token_env": "WORKER_TOKEN",
6683                    "ca_cert_file": "/etc/bamboo/worker.pem"
6684                },
6685                { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6686            ]
6687        }"#;
6688        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6689        assert_eq!(cfg.remote_placements.len(), 2);
6690        let p0 = &cfg.remote_placements[0];
6691        assert_eq!(p0.role, "explorer");
6692        assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6693        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6694        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6695        // Optional fields default to None and are skipped on serialize.
6696        let p1 = &cfg.remote_placements[1];
6697        assert_eq!(p1.role, "writer");
6698        assert!(p1.token_env.is_none());
6699        assert!(p1.ca_cert_file.is_none());
6700
6701        let back = serde_json::to_string(&cfg).unwrap();
6702        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6703        assert_eq!(cfg, reparsed, "round-trip is stable");
6704        assert!(!back.contains("\"token_env\":null"));
6705        assert!(!back.contains("\"ca_cert_file\":null"));
6706    }
6707
6708    #[test]
6709    fn subagents_config_without_schedulable_placements_deserializes_empty() {
6710        // An OLD config (predating P2b) has no `schedulable_placements` key — it
6711        // must still deserialize, with an empty list (default = local path).
6712        let json = r#"{ "max_concurrent": 4 }"#;
6713        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6714        assert!(cfg.schedulable_placements.is_empty());
6715        // An empty list is omitted on re-serialize (skip_if empty).
6716        let back = serde_json::to_string(&cfg).unwrap();
6717        assert!(
6718            !back.contains("schedulable_placements"),
6719            "empty vec is skipped: {back}"
6720        );
6721    }
6722
6723    #[test]
6724    fn schedulable_placement_round_trips() {
6725        let json = r#"{
6726            "schedulable_placements": [
6727                {
6728                    "role": "explorer",
6729                    "pool": "gpu-pool",
6730                    "registry_url": "https://control-plane:9562",
6731                    "token_env": "WORKER_TOKEN",
6732                    "ca_cert_file": "/etc/bamboo/worker.pem"
6733                },
6734                { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6735            ]
6736        }"#;
6737        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6738        assert_eq!(cfg.schedulable_placements.len(), 2);
6739        let p0 = &cfg.schedulable_placements[0];
6740        assert_eq!(p0.role, "explorer");
6741        assert_eq!(p0.pool, "gpu-pool");
6742        assert_eq!(p0.registry_url, "https://control-plane:9562");
6743        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6744        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6745        // Optional fields default to None and are skipped on serialize.
6746        let p1 = &cfg.schedulable_placements[1];
6747        assert_eq!(p1.role, "writer");
6748        assert_eq!(p1.pool, "cpu-pool");
6749        assert!(p1.token_env.is_none());
6750        assert!(p1.ca_cert_file.is_none());
6751
6752        let back = serde_json::to_string(&cfg).unwrap();
6753        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6754        assert_eq!(cfg, reparsed, "round-trip is stable");
6755        assert!(!back.contains("\"token_env\":null"));
6756        assert!(!back.contains("\"ca_cert_file\":null"));
6757    }
6758
6759    #[test]
6760    fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6761        // An OLD config (predating #54's wiring) has no `mcp_role_allowlist`
6762        // key — it must still deserialize, with an empty list (default =
6763        // every role unrestricted, identical to pre-#54 behavior).
6764        let json = r#"{ "max_concurrent": 4 }"#;
6765        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6766        assert!(cfg.mcp_role_allowlist.is_empty());
6767        // An empty list is omitted on re-serialize (skip_if empty).
6768        let back = serde_json::to_string(&cfg).unwrap();
6769        assert!(
6770            !back.contains("mcp_role_allowlist"),
6771            "empty vec is skipped: {back}"
6772        );
6773    }
6774
6775    #[test]
6776    fn mcp_role_allowlist_entry_round_trips() {
6777        let json = r#"{
6778            "mcp_role_allowlist": [
6779                { "role": "researcher", "tools": ["fetch_url"] },
6780                { "role": "sandboxed", "tools": [] }
6781            ]
6782        }"#;
6783        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6784        assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6785        assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6786        assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6787        // An empty `tools` list is an explicit lockout, distinct from the role
6788        // being absent — it must round-trip as an empty (not omitted) list.
6789        assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6790        assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6791
6792        let back = serde_json::to_string(&cfg).unwrap();
6793        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6794        assert_eq!(cfg, reparsed, "round-trip is stable");
6795    }
6796
6797    #[test]
6798    fn server_config_without_tls_field_deserializes_back_compat() {
6799        // An old config.json `server` section with no `tls` key must still
6800        // deserialize, leaving `tls` as None (zero behavior change on upgrade).
6801        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6802            "port": 9562,
6803            "bind": "127.0.0.1"
6804        }))
6805        .expect("legacy server config without tls should deserialize");
6806
6807        assert_eq!(server.tls, None);
6808        assert_eq!(server.port, 9562);
6809        assert_eq!(server.bind, "127.0.0.1");
6810    }
6811
6812    #[test]
6813    fn server_config_omits_tls_when_none() {
6814        // `skip_serializing_if = "Option::is_none"` keeps the on-disk shape
6815        // identical to before for the common (no-TLS) case.
6816        let server = ServerConfig::default();
6817        let value = serde_json::to_value(&server).expect("server config should serialize");
6818        let obj = value
6819            .as_object()
6820            .expect("server config serializes to object");
6821        assert!(
6822            !obj.contains_key("tls"),
6823            "tls must be omitted when None, got: {value}"
6824        );
6825    }
6826
6827    #[test]
6828    fn server_config_with_tls_roundtrips() {
6829        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6830            "port": 9562,
6831            "bind": "0.0.0.0",
6832            "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6833        }))
6834        .expect("server config with tls should deserialize");
6835
6836        let tls = server.tls.clone().expect("tls should be Some");
6837        assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6838        assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6839
6840        // Round-trips: tls survives a serialize → deserialize cycle.
6841        let value = serde_json::to_value(&server).expect("serialize");
6842        assert!(value.as_object().unwrap().contains_key("tls"));
6843        let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6844        assert_eq!(back.tls, server.tls);
6845    }
6846
6847    #[test]
6848    fn access_control_without_devices_field_deserializes_back_compat() {
6849        // An old config.json `access_control` with no `devices` key must still
6850        // deserialize, leaving `devices` empty (root-password-only mode).
6851        let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6852            "password_enabled": true,
6853            "password_hash": "deadbeef",
6854            "password_salt": "01020304",
6855        }))
6856        .expect("legacy access_control without devices should deserialize");
6857
6858        assert!(access.devices.is_empty());
6859        assert!(access.password_enabled);
6860    }
6861
6862    #[test]
6863    fn access_control_omits_devices_when_empty() {
6864        // `skip_serializing_if = "Vec::is_empty"` keeps the on-disk shape
6865        // identical for instances that never paired a device.
6866        let access = AccessControlConfig {
6867            password_enabled: true,
6868            repair_required: false,
6869            password_hash: Some("deadbeef".to_string()),
6870            password_salt: Some("01020304".to_string()),
6871            password_credential_ref: None,
6872            password_configured: false,
6873            updated_at: None,
6874            devices: Vec::new(),
6875        };
6876        let value = serde_json::to_value(&access).expect("serialize");
6877        let obj = value.as_object().expect("object");
6878        assert!(
6879            !obj.contains_key("devices"),
6880            "devices must be omitted when empty, got: {value}"
6881        );
6882    }
6883
6884    #[test]
6885    fn access_control_with_devices_roundtrips() {
6886        let device = DeviceCredential {
6887            device_id: "bamboo_0123456789ab".to_string(),
6888            label: "iPhone 15".to_string(),
6889            token_hash: "abcd".to_string(),
6890            token_salt: "ef01".to_string(),
6891            token_credential_ref: None,
6892            token_configured: false,
6893            created_at: "2026-06-23T00:00:00Z".to_string(),
6894            last_used_at: None,
6895            revoked: false,
6896        };
6897        let access = AccessControlConfig {
6898            password_enabled: true,
6899            repair_required: false,
6900            password_hash: Some("deadbeef".to_string()),
6901            password_salt: Some("01020304".to_string()),
6902            password_credential_ref: None,
6903            password_configured: false,
6904            updated_at: None,
6905            devices: vec![device.clone()],
6906        };
6907        let value = serde_json::to_value(&access).expect("serialize");
6908        assert!(value.as_object().unwrap().contains_key("devices"));
6909        assert!(value["devices"][0].get("token_hash").is_none());
6910        assert!(value["devices"][0].get("token_salt").is_none());
6911        let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6912        assert_eq!(back.devices[0].device_id, device.device_id);
6913        assert!(back.devices[0].token_hash.is_empty());
6914        assert!(back.devices[0].token_salt.is_empty());
6915    }
6916
6917    #[test]
6918    fn reasoning_effort_for_key_resolves_instance_id() {
6919        // Multi-instance mode: the routing key is an instance id and the effort
6920        // lives under provider_instances[<id>] — previously this fell through to
6921        // None because the resolver only matched literal provider types.
6922        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6923            "provider_type": "copilot",
6924            "reasoning_effort": "high",
6925        }))
6926        .expect("instance config should deserialize");
6927
6928        let mut config = Config::create_default();
6929        config
6930            .provider_instances
6931            .insert("copilot-work".to_string(), instance);
6932
6933        assert_eq!(
6934            config.reasoning_effort_for_key("copilot-work"),
6935            Some(ReasoningEffort::High),
6936        );
6937    }
6938
6939    #[test]
6940    fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6941        // Legacy mode: the `bodhi` provider previously had no match arm.
6942        let mut config = Config::create_default();
6943        config.providers.bodhi = Some(
6944            serde_json::from_value(serde_json::json!({
6945                "reasoning_effort": "xhigh",
6946            }))
6947            .expect("bodhi config should deserialize"),
6948        );
6949
6950        assert_eq!(
6951            config.reasoning_effort_for_key("bodhi"),
6952            Some(ReasoningEffort::Xhigh),
6953        );
6954    }
6955
6956    #[test]
6957    fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6958        let mut config = Config::create_default();
6959        config.providers.openai = Some(
6960            serde_json::from_value(serde_json::json!({
6961                "api_key": "sk-test",
6962                "reasoning_effort": "low",
6963            }))
6964            .expect("openai config should deserialize"),
6965        );
6966
6967        assert_eq!(
6968            config.reasoning_effort_for_key("openai"),
6969            Some(ReasoningEffort::Low),
6970        );
6971    }
6972
6973    #[test]
6974    fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6975        let config = Config::create_default();
6976        assert_eq!(config.reasoning_effort_for_key("nope"), None);
6977        assert_eq!(config.reasoning_effort_for_key("   "), None);
6978    }
6979
6980    struct TempHome {
6981        path: PathBuf,
6982    }
6983
6984    impl TempHome {
6985        fn new() -> Self {
6986            // `pid + nanos` alone is NOT collision-free (issue #486): every
6987            // test in this binary shares the pid, and two tests started
6988            // concurrently by the multi-threaded harness can observe the
6989            // same `SystemTime` nanos tick. Two `TempHome`s colliding on one
6990            // path means they share a directory — and the first test's
6991            // `Drop` (`remove_dir_all`) then yanks the directory out from
6992            // under the other test's in-flight `save_to_dir`, whose
6993            // tmp-file+rename dance fails with ENOENT ("Failed to write
6994            // config file ... os error 2" — `save_rotates_backup_generations`'s
6995            // exact one-off CI failure mode). A per-process atomic counter
6996            // in the name makes each instance unique unconditionally.
6997            static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6998                std::sync::atomic::AtomicU64::new(0);
6999            let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7000            let nanos = SystemTime::now()
7001                .duration_since(UNIX_EPOCH)
7002                .expect("clock should be after unix epoch")
7003                .as_nanos();
7004            let path = std::env::temp_dir().join(format!(
7005                "chat-core-config-test-{}-{}-{}",
7006                std::process::id(),
7007                nanos,
7008                unique
7009            ));
7010            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
7011            Self { path }
7012        }
7013
7014        fn set_config_json(&self, content: &str) {
7015            // Treat `path` as the Bamboo data dir and write `config.json` into it.
7016            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
7017            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
7018            std::fs::write(self.path.join("config.json"), content)
7019                .expect("failed to write config.json");
7020        }
7021    }
7022
7023    impl Drop for TempHome {
7024        fn drop(&mut self) {
7025            let _ = std::fs::remove_dir_all(&self.path);
7026        }
7027    }
7028
7029    // Delegate to the single crate-wide test lock so env-mutating tests across
7030    // `config`, `encryption`, and `paths` serialize against one another (they
7031    // all mutate the same process-global env / static caches).
7032    fn env_lock() -> &'static Mutex<()> {
7033        crate::test_support::env_cache_lock()
7034    }
7035
7036    /// Acquire the environment lock, recovering from poison if a previous test failed
7037    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
7038        env_lock().lock().unwrap_or_else(|poisoned| {
7039            // Lock was poisoned by a previous test failure - recover it
7040            poisoned.into_inner()
7041        })
7042    }
7043
7044    #[test]
7045    fn parse_bool_env_true_values() {
7046        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
7047            assert!(parse_bool_env(value), "value {value:?} should be true");
7048        }
7049    }
7050
7051    #[test]
7052    fn parse_bool_env_false_values() {
7053        for value in ["0", "false", "no", "off", "", "  "] {
7054            assert!(!parse_bool_env(value), "value {value:?} should be false");
7055        }
7056    }
7057
7058    #[test]
7059    fn config_new_ignores_http_proxy_env_vars() {
7060        let _lock = env_lock_acquire();
7061        let temp_home = TempHome::new();
7062        temp_home.set_config_json(
7063            r#"{
7064  "http_proxy": "",
7065  "https_proxy": ""
7066}"#,
7067        );
7068
7069        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
7070        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
7071
7072        let config = Config::from_data_dir(Some(temp_home.path.clone()));
7073
7074        assert!(
7075            config.http_proxy.is_empty(),
7076            "config should ignore HTTP_PROXY env var"
7077        );
7078        assert!(
7079            config.https_proxy.is_empty(),
7080            "config should ignore HTTPS_PROXY env var"
7081        );
7082    }
7083
7084    #[test]
7085    fn config_new_loads_config_when_proxy_fields_omitted() {
7086        let _lock = env_lock_acquire();
7087        let temp_home = TempHome::new();
7088        temp_home.set_config_json(
7089            r#"{
7090  "provider": "openai",
7091  "providers": {
7092    "openai": {
7093      "api_key": "sk-test",
7094      "model": "gpt-4o"
7095    }
7096  }
7097}"#,
7098        );
7099
7100        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
7101        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
7102
7103        let config = Config::from_data_dir(Some(temp_home.path.clone()));
7104
7105        assert_eq!(
7106            config
7107                .providers
7108                .openai
7109                .as_ref()
7110                .and_then(|c| c.model.as_deref()),
7111            Some("gpt-4o"),
7112            "config should load provider model from config file even when proxy fields are omitted"
7113        );
7114        assert!(config.http_proxy.is_empty());
7115        assert!(config.https_proxy.is_empty());
7116    }
7117
7118    #[test]
7119    fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
7120        let _lock = crate::test_support::env_cache_lock_acquire();
7121        let mut config = Config::default();
7122        config.env_vars.extend([
7123            EnvVarEntry {
7124                name: "SECRET_TOKEN".to_string(),
7125                value: "top-secret".to_string(),
7126                secret: true,
7127                value_encrypted: None,
7128                credential_ref: None,
7129                configured: true,
7130                description: Some("Service token".to_string()),
7131            },
7132            EnvVarEntry {
7133                name: "API_BASE".to_string(),
7134                value: "https://internal.example".to_string(),
7135                secret: false,
7136                value_encrypted: None,
7137                credential_ref: None,
7138                configured: true,
7139                description: Some("Internal API base".to_string()),
7140            },
7141        ]);
7142
7143        config.publish_env_vars();
7144
7145        let injected = Config::current_env_vars();
7146        assert_eq!(
7147            injected.get("SECRET_TOKEN").map(String::as_str),
7148            Some("top-secret")
7149        );
7150        assert_eq!(
7151            injected.get("API_BASE").map(String::as_str),
7152            Some("https://internal.example")
7153        );
7154
7155        let prompt_safe = Config::current_prompt_safe_env_vars();
7156        assert_eq!(prompt_safe.len(), 2);
7157        assert!(prompt_safe.iter().any(|entry| {
7158            entry.name == "SECRET_TOKEN"
7159                && entry.secret
7160                && entry.description.as_deref() == Some("Service token")
7161        }));
7162        assert!(prompt_safe.iter().any(|entry| {
7163            entry.name == "API_BASE"
7164                && !entry.secret
7165                && entry.description.as_deref() == Some("Internal API base")
7166        }));
7167        assert!(!prompt_safe
7168            .iter()
7169            .any(|entry| entry.name.contains("top-secret")));
7170        assert!(!prompt_safe.iter().any(|entry| {
7171            entry
7172                .description
7173                .as_deref()
7174                .is_some_and(|value| value.contains("https://internal.example"))
7175        }));
7176    }
7177
7178    #[test]
7179    fn from_data_dir_without_publish_does_not_clobber_global_cache() {
7180        let _lock = crate::test_support::env_cache_lock_acquire();
7181
7182        // Seed the global cache with a marker "owned" by the live config.
7183        let mut live = Config::default();
7184        live.env_vars.extend([EnvVarEntry {
7185            name: "BAMBOO_CACHE_OWNER_40".to_string(),
7186            value: "live".to_string(),
7187            secret: false,
7188            value_encrypted: None,
7189            credential_ref: None,
7190            configured: true,
7191            description: None,
7192        }]);
7193        live.publish_env_vars();
7194        assert_eq!(
7195            Config::current_env_vars()
7196                .get("BAMBOO_CACHE_OWNER_40")
7197                .map(String::as_str),
7198            Some("live")
7199        );
7200
7201        // A config.json on disk sets the SAME var to a different (stale) value.
7202        let temp = TempHome::new();
7203        temp.set_config_json(
7204            &serde_json::json!({
7205                "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
7206            })
7207            .to_string(),
7208        );
7209
7210        // Non-publishing load reads the disk value into the returned Config but
7211        // must NOT touch the global cache.
7212        let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7213        assert_eq!(
7214            loaded
7215                .env_vars
7216                .iter()
7217                .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
7218                .map(|e| e.value.as_str()),
7219            Some("stale-disk"),
7220            "the returned Config holds the disk value"
7221        );
7222        assert_eq!(
7223            Config::current_env_vars()
7224                .get("BAMBOO_CACHE_OWNER_40")
7225                .map(String::as_str),
7226            Some("live"),
7227            "but the global cache is UNTOUCHED — no clobber (#40)"
7228        );
7229
7230        // Contrast: the publishing variant DOES clobber the cache.
7231        let _ = Config::from_data_dir(Some(temp.path.clone()));
7232        assert_eq!(
7233            Config::current_env_vars()
7234                .get("BAMBOO_CACHE_OWNER_40")
7235                .map(String::as_str),
7236            Some("stale-disk"),
7237            "the publishing loader clobbers the cache (contrast)"
7238        );
7239    }
7240
7241    fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
7242        std::fs::read_dir(dir)
7243            .unwrap()
7244            .filter_map(|e| e.ok())
7245            .any(|e| {
7246                e.file_name()
7247                    .to_string_lossy()
7248                    .contains("config.json.corrupted.")
7249            })
7250    }
7251
7252    #[test]
7253    fn corrupt_config_recovered_from_backup_and_quarantined() {
7254        let temp = TempHome::new();
7255        // Last-known-good backup with a distinctive value.
7256        std::fs::write(
7257            temp.path.join("config.json.bak"),
7258            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7259        )
7260        .unwrap();
7261        // Corrupt primary config.json.
7262        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7263
7264        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7265        assert_eq!(
7266            config.http_proxy, "http://from-backup",
7267            "recovered from config.json.bak instead of losing all config"
7268        );
7269        assert!(
7270            dir_has_quarantine_file(&temp.path),
7271            "corrupt config.json was quarantined (preserved), not discarded"
7272        );
7273    }
7274
7275    #[test]
7276    fn corrupt_config_without_backup_quarantines_then_defaults() {
7277        let temp = TempHome::new();
7278        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7279
7280        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7281        assert!(
7282            config.http_proxy.is_empty(),
7283            "no backup -> falls back to defaults"
7284        );
7285        assert!(
7286            dir_has_quarantine_file(&temp.path),
7287            "corrupt config.json is quarantined even when there's no backup"
7288        );
7289    }
7290
7291    #[test]
7292    fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
7293        let temp = TempHome::new();
7294        // A valid JSON OBJECT, but `env_vars` is the wrong type (string, not array)
7295        // so STRICT parse fails. There is NO config.json.bak, so recovery must come
7296        // from field-level salvage: `http_proxy` is valid and must survive; the bad
7297        // `env_vars` resets to its default.
7298        temp.set_config_json(
7299            r#"{"http_proxy":"http://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://salvaged",
7305            "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
7306        );
7307        assert!(
7308            config.env_vars.is_empty(),
7309            "the corrupt field reset to its default instead of failing the whole load"
7310        );
7311        assert!(
7312            dir_has_quarantine_file(&temp.path),
7313            "the corrupt config.json was still quarantined for inspection"
7314        );
7315    }
7316
7317    #[test]
7318    fn salvage_preferred_over_backup_for_most_recent_intent() {
7319        let temp = TempHome::new();
7320        // An OLDER last-known-good backup...
7321        std::fs::write(
7322            temp.path.join("config.json.bak"),
7323            serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
7324        )
7325        .unwrap();
7326        // ...and a NEWER config that is corrupt but field-salvageable.
7327        temp.set_config_json(
7328            r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
7329        );
7330
7331        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7332        assert_eq!(
7333            config.http_proxy, "http://new-salvaged",
7334            "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7335        );
7336    }
7337
7338    #[test]
7339    fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7340        let temp = TempHome::new();
7341        // Backup carries TWO good values.
7342        std::fs::write(
7343            temp.path.join("config.json.bak"),
7344            serde_json::json!({
7345                "http_proxy": "http://old-from-backup",
7346                "https_proxy": "https://kept-from-backup",
7347            })
7348            .to_string(),
7349        )
7350        .unwrap();
7351        // The corrupt file updates http_proxy (newer), leaves https_proxy untouched,
7352        // and has one wrong-type field.
7353        temp.set_config_json(
7354            r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7355        );
7356
7357        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7358        // Best of both: the corrupt file's newer valid edit wins where it set one...
7359        assert_eq!(
7360            config.http_proxy, "http://newer-edit",
7361            "the corrupt file's newer valid edit is applied"
7362        );
7363        // ...and the backup's value survives for fields the corrupt file didn't fix.
7364        assert_eq!(
7365            config.https_proxy, "https://kept-from-backup",
7366            "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7367        );
7368    }
7369
7370    #[test]
7371    fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7372        let temp = TempHome::new();
7373        std::fs::write(
7374            temp.path.join("config.json.bak"),
7375            serde_json::json!({
7376                "providers": {
7377                    "anthropic": { "model": "claude-backup" }
7378                },
7379                "memory": {
7380                    "auto_dream_enabled": true
7381                },
7382                "subagents": {
7383                    "claude_code_model": "claude-code-backup"
7384                }
7385            })
7386            .to_string(),
7387        )
7388        .unwrap();
7389        assert!(!temp.path.join("providers.json").exists());
7390        assert!(!temp.path.join("memory.json").exists());
7391        assert!(!temp.path.join("subagents.json").exists());
7392
7393        let (salvaged, recovered_fields) = Config::salvage_partial(
7394            r#"{"providers":"schema-invalid-provider-section"}"#,
7395            &temp.path,
7396        )
7397        .expect("object-shaped corrupt config should be salvageable");
7398
7399        assert!(
7400            recovered_fields.is_empty(),
7401            "the schema-invalid provider field must not replace the backup baseline"
7402        );
7403        assert_eq!(
7404            salvaged
7405                .providers()
7406                .anthropic
7407                .as_ref()
7408                .and_then(|provider| provider.model.as_deref()),
7409            Some("claude-backup")
7410        );
7411        assert!(
7412            salvaged
7413                .memory()
7414                .as_ref()
7415                .expect("backup memory config survives")
7416                .auto_dream_enabled
7417        );
7418        assert_eq!(
7419            salvaged.subagents().claude_code_model.as_deref(),
7420            Some("claude-code-backup")
7421        );
7422    }
7423
7424    #[test]
7425    fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7426        let temp = TempHome::new();
7427        // Not even a JSON object -> nothing field-wise to salvage -> must fall
7428        // through to the .bak (the pre-#135 behavior is preserved).
7429        std::fs::write(
7430            temp.path.join("config.json.bak"),
7431            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7432        )
7433        .unwrap();
7434        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7435
7436        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7437        assert_eq!(
7438            config.http_proxy, "http://from-backup",
7439            "garbage (non-object) config skips salvage and recovers from .bak"
7440        );
7441    }
7442
7443    #[test]
7444    fn quarantine_files_are_capped_to_newest_n() {
7445        let temp = TempHome::new();
7446        let config_path = temp.path.join("config.json");
7447        std::fs::write(&config_path, "{}").unwrap();
7448
7449        // Drop more quarantines than the cap; each call sleeps so nanos (the name)
7450        // and mtime (the prune sort key) are distinct.
7451        for _ in 0..(QUARANTINE_KEEP + 3) {
7452            quarantine_corrupt_config(&config_path);
7453            std::thread::sleep(std::time::Duration::from_millis(3));
7454        }
7455
7456        let count = std::fs::read_dir(&temp.path)
7457            .unwrap()
7458            .filter_map(|e| e.ok())
7459            .filter(|e| {
7460                e.file_name()
7461                    .to_string_lossy()
7462                    .starts_with("config.json.corrupted.")
7463            })
7464            .count();
7465        assert_eq!(
7466            count, QUARANTINE_KEEP,
7467            "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7468        );
7469    }
7470
7471    #[test]
7472    fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7473        let temp = TempHome::new();
7474        // Primary AND the freshest .bak are corrupt; an older generation is good.
7475        std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7476        std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7477        std::fs::write(
7478            temp.path.join("config.json.bak.1"),
7479            serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7480        )
7481        .unwrap();
7482
7483        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7484        assert_eq!(
7485            config.http_proxy, "http://from-gen-1",
7486            "recovered from .bak.1 when both config.json and .bak are corrupt"
7487        );
7488    }
7489
7490    #[test]
7491    fn save_rotates_backup_generations() {
7492        let temp = TempHome::new();
7493        let path = temp.path.join("config.json");
7494        // v1 is the existing on-disk config.
7495        std::fs::write(
7496            &path,
7497            serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7498        )
7499        .unwrap();
7500
7501        let mut cfg = Config::create_default();
7502        // Save 1: backs up the existing v1 -> .bak, writes v2.
7503        cfg.http_proxy = "http://proxy-v2".to_string();
7504        cfg.save_to_dir(temp.path.clone()).unwrap();
7505        // Save 2: existing (v2) is parseable -> rotate .bak(v1) -> .bak.1, .bak = v2.
7506        cfg.http_proxy = "http://proxy-v3".to_string();
7507        cfg.save_to_dir(temp.path.clone()).unwrap();
7508
7509        let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7510        let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7511        assert!(
7512            bak.contains("proxy-v2"),
7513            ".bak holds the previous generation (v2)"
7514        );
7515        assert!(
7516            bak1.contains("proxy-v1"),
7517            ".bak.1 holds the older rotated generation (v1)"
7518        );
7519    }
7520
7521    #[test]
7522    fn save_backs_up_existing_config() {
7523        let temp = TempHome::new();
7524        // Existing (old) config on disk.
7525        std::fs::write(
7526            temp.path.join("config.json"),
7527            serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7528        )
7529        .unwrap();
7530
7531        let mut config = Config::create_default();
7532        config.http_proxy = "http://new".to_string();
7533        config
7534            .save_to_dir(temp.path.clone())
7535            .expect("save succeeds");
7536
7537        let backup =
7538            std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7539        assert!(
7540            backup.contains("http://old"),
7541            "config.json.bak holds the PREVIOUS config (last-known-good)"
7542        );
7543        let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7544        assert!(
7545            current.contains("http://new"),
7546            "config.json holds the new config"
7547        );
7548    }
7549
7550    #[test]
7551    fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7552        let temp = TempHome::new();
7553        // A good last-known-good backup...
7554        std::fs::write(
7555            temp.path.join("config.json.bak"),
7556            serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7557        )
7558        .unwrap();
7559        // ...but the on-disk config.json is corrupt (as it would be right after an
7560        // in-memory recovery, before any clean save).
7561        std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7562
7563        let mut config = Config::create_default();
7564        config.http_proxy = "http://new".to_string();
7565        config
7566            .save_to_dir(temp.path.clone())
7567            .expect("save succeeds");
7568
7569        // The good .bak must NOT have been clobbered by the corrupt config.json.
7570        let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7571        assert!(
7572            backup.contains("http://good-bak"),
7573            "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7574        );
7575    }
7576
7577    // ── config-corruption recovery confirmation gate (#153) ───────────────
7578
7579    #[test]
7580    fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7581        let temp = TempHome::new();
7582        std::fs::write(
7583            temp.path.join("config.json.bak"),
7584            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7585        )
7586        .unwrap();
7587        let corrupt_bytes = "{ not valid json ";
7588        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7589
7590        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7591        let status = config
7592            .recovery_status()
7593            .expect("a corrupt load must set a pending recovery status");
7594        assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7595        assert_eq!(
7596            status.source,
7597            ConfigRecoverySource::Backup { generation: 0 },
7598            "recovered from generation-0 (.bak)"
7599        );
7600        let quarantine_path = status
7601            .quarantine_path
7602            .as_ref()
7603            .expect("quarantine copy should have succeeded");
7604        assert_eq!(
7605            std::fs::read_to_string(quarantine_path).unwrap(),
7606            corrupt_bytes,
7607            "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7608        );
7609        assert_eq!(
7610            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7611            corrupt_bytes,
7612            "the original config.json itself is untouched by the load (only copied, not moved)"
7613        );
7614    }
7615
7616    #[test]
7617    fn recovery_status_set_from_salvage_lists_recovered_fields() {
7618        let temp = TempHome::new();
7619        temp.set_config_json(
7620            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7621        );
7622
7623        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7624        let status = config.recovery_status().expect("pending recovery");
7625        assert!(!status.confirmed);
7626        match &status.source {
7627            ConfigRecoverySource::Salvaged { fields } => {
7628                assert!(
7629                    fields.iter().any(|f| f == "http_proxy"),
7630                    "salvaged fields should list the recovered key: {fields:?}"
7631                );
7632            }
7633            other => panic!("expected Salvaged source, got {other:?}"),
7634        }
7635    }
7636
7637    #[test]
7638    fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7639        let temp = TempHome::new();
7640        // Not a JSON object at all -> salvage impossible; no .bak -> defaults.
7641        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7642
7643        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7644        let status = config.recovery_status().expect("pending recovery");
7645        assert!(!status.confirmed);
7646        assert_eq!(status.source, ConfigRecoverySource::Defaults);
7647    }
7648
7649    #[test]
7650    fn clean_load_never_sets_recovery_status() {
7651        let temp = TempHome::new();
7652        temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7653
7654        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7655        assert!(
7656            config.recovery_status().is_none(),
7657            "a config.json that parses cleanly must never carry a pending recovery status"
7658        );
7659    }
7660
7661    #[test]
7662    fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7663        let temp = TempHome::new();
7664        let corrupt_bytes = "}}} broken";
7665        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7666
7667        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7668        assert!(config.recovery_status().is_some());
7669
7670        let err = config
7671            .save_to_dir(temp.path.clone())
7672            .expect_err("save must refuse while recovery is unconfirmed");
7673        assert!(
7674            err.to_string().contains("recovered from corruption")
7675                || err.to_string().contains("confirm"),
7676            "error should explain the refused overwrite: {err}"
7677        );
7678
7679        // The corrupt original on disk must be BYTE FOR BYTE unchanged — the
7680        // refused save must not have touched it at all.
7681        assert_eq!(
7682            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7683            corrupt_bytes,
7684            "a refused save must leave the corrupt original untouched"
7685        );
7686    }
7687
7688    #[test]
7689    fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7690        let temp = TempHome::new();
7691        // Simulates a crash mid-write: valid JSON prefix, abruptly cut off.
7692        let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7693        std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7694
7695        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7696        let status = config.recovery_status().expect("pending recovery");
7697        let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7698        assert_eq!(
7699            std::fs::read_to_string(quarantine_path).unwrap(),
7700            truncated,
7701            "truncated original preserved byte for byte in quarantine"
7702        );
7703
7704        let err = config.save_to_dir(temp.path.clone());
7705        assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7706        assert_eq!(
7707            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7708            truncated,
7709            "the half-written original stays exactly as it was after a refused save"
7710        );
7711    }
7712
7713    #[test]
7714    fn confirm_recovery_allows_the_next_save() {
7715        let temp = TempHome::new();
7716        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7717
7718        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7719        assert!(config.recovery_status().is_some());
7720
7721        config.confirm_recovery();
7722        assert!(
7723            config.recovery_status().is_some_and(|s| s.confirmed),
7724            "confirm_recovery flips the flag but keeps the status around"
7725        );
7726
7727        config
7728            .save_to_dir(temp.path.clone())
7729            .expect("save must succeed once the recovery is confirmed");
7730    }
7731
7732    #[test]
7733    fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7734        let temp = TempHome::new();
7735        std::fs::write(
7736            temp.path.join("config.json"),
7737            r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7738        )
7739        .unwrap();
7740
7741        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7742        assert!(config.recovery_status().is_some());
7743        let quarantine_path = config
7744            .recovery_status()
7745            .unwrap()
7746            .quarantine_path
7747            .clone()
7748            .unwrap();
7749
7750        config
7751            .confirm_recovery_and_save_to_dir(temp.path.clone())
7752            .expect("confirm+save should succeed");
7753
7754        assert!(
7755            config.recovery_status().is_none(),
7756            "the pending flag is cleared once the recovery is confirmed and persisted"
7757        );
7758        let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7759        assert!(
7760            on_disk.contains("http://recovered"),
7761            "config.json now holds the recovered (salvaged) state"
7762        );
7763        // The quarantine copy of the original corrupt file must still exist,
7764        // untouched, even after the recovery is confirmed and persisted.
7765        assert!(
7766            quarantine_path.exists(),
7767            "the quarantined original survives confirmation — it's never deleted"
7768        );
7769    }
7770
7771    #[test]
7772    fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7773        let temp = TempHome::new();
7774        let mut config = Config::create_default();
7775        let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7776        assert!(
7777            err.is_err(),
7778            "confirming a recovery that was never pending must error, not silently succeed"
7779        );
7780    }
7781
7782    // ── connect.json split (#455) ────────────────────────────────────────
7783
7784    fn connect_platform_with_encrypted(
7785        platform_type: &str,
7786        token_encrypted: &str,
7787    ) -> ConnectPlatformConfig {
7788        ConnectPlatformConfig {
7789            id: None,
7790            project_id: None,
7791            platform_type: platform_type.to_string(),
7792            token: None,
7793            token_encrypted: Some(token_encrypted.to_string()),
7794            token_credential_ref: None,
7795            token_configured: false,
7796            app_id: None,
7797            app_secret: None,
7798            app_secret_encrypted: None,
7799            app_secret_credential_ref: None,
7800            app_secret_configured: false,
7801            domain: None,
7802            allow_from: vec!["user-1".to_string()],
7803            admin_from: Vec::new(),
7804        }
7805    }
7806
7807    fn connect_json_path(temp: &TempHome) -> PathBuf {
7808        temp.path.join("connect.json")
7809    }
7810
7811    #[test]
7812    fn save_splits_connect_into_sibling_connect_json() {
7813        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7814        let temp = TempHome::new();
7815
7816        let mut config = Config::create_default();
7817        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7818        config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7819
7820        config
7821            .save_to_dir(temp.path.clone())
7822            .expect("save succeeds");
7823
7824        let config_json: serde_json::Value =
7825            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7826                .unwrap();
7827        assert!(
7828            config_json.get("connect").is_none(),
7829            "config.json must not carry the `connect` key after a save"
7830        );
7831
7832        let connect_json: serde_json::Value =
7833            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7834                .unwrap();
7835        assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7836        assert!(
7837            connect_json["platforms"][0]["token_encrypted"]
7838                .as_str()
7839                .is_some_and(|v| !v.is_empty()),
7840            "the token is persisted in its encrypted form in connect.json"
7841        );
7842        assert!(
7843            connect_json["platforms"][0].get("token").is_none(),
7844            "the plaintext token is never persisted (skip_serializing)"
7845        );
7846    }
7847
7848    // ── stable connect.platforms id (#496) ───────────────────────────────
7849
7850    #[test]
7851    fn save_assigns_a_missing_connect_platform_id() {
7852        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7853        let temp = TempHome::new();
7854
7855        let mut config = Config::create_default();
7856        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7857        assert!(
7858            config.connect.platforms[0].id.is_none(),
7859            "precondition: the entry starts without an id"
7860        );
7861
7862        config
7863            .save_to_dir(temp.path.clone())
7864            .expect("save succeeds");
7865
7866        let connect_json: serde_json::Value =
7867            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7868                .unwrap();
7869        let persisted_id = connect_json["platforms"][0]["id"]
7870            .as_str()
7871            .expect("save_to_dir must backfill a missing id onto the persisted entry");
7872        assert!(!persisted_id.is_empty());
7873
7874        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7875        assert_eq!(
7876            reloaded.connect.platforms[0].id.as_deref(),
7877            Some(persisted_id),
7878            "the assigned id round-trips through a reload"
7879        );
7880    }
7881
7882    #[test]
7883    fn save_never_reassigns_an_existing_connect_platform_id() {
7884        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7885        let temp = TempHome::new();
7886
7887        let mut config = Config::create_default();
7888        let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7889        platform.id = Some("stable-id-123".to_string());
7890        config.connect.platforms = vec![platform];
7891
7892        config
7893            .save_to_dir(temp.path.clone())
7894            .expect("first save succeeds");
7895        // Save again (e.g. an unrelated settings change) — the id must not change.
7896        config
7897            .save_to_dir(temp.path.clone())
7898            .expect("second save succeeds");
7899
7900        let connect_json: serde_json::Value =
7901            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7902                .unwrap();
7903        assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7904    }
7905
7906    #[test]
7907    fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7908        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7909        let temp = TempHome::new();
7910
7911        let mut config = Config::create_default();
7912        config.connect.platforms = vec![
7913            connect_platform_with_encrypted("telegram", "cipher-a"),
7914            connect_platform_with_encrypted("telegram", "cipher-b"),
7915        ];
7916
7917        config
7918            .save_to_dir(temp.path.clone())
7919            .expect("save succeeds");
7920
7921        let connect_json: serde_json::Value =
7922            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7923                .unwrap();
7924        let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7925        let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7926        assert_ne!(
7927            id_a, id_b,
7928            "two entries sharing platform_type must still get distinct ids"
7929        );
7930    }
7931
7932    #[test]
7933    fn load_never_assigns_or_persists_an_id_by_itself() {
7934        let temp = TempHome::new();
7935        std::fs::write(
7936            connect_json_path(&temp),
7937            serde_json::json!({
7938                "platforms": [
7939                    { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7940                ]
7941            })
7942            .to_string(),
7943        )
7944        .unwrap();
7945        let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7946
7947        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7948
7949        assert!(
7950            config.connect.platforms[0].id.is_none(),
7951            "load alone must not backfill an id in memory"
7952        );
7953        let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7954        assert_eq!(
7955            connect_json_before, connect_json_after,
7956            "load must never rewrite connect.json on disk just to backfill an id (#493)"
7957        );
7958    }
7959
7960    #[test]
7961    fn load_merges_connect_json_into_config() {
7962        let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7963        let temp = TempHome::new();
7964        let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7965        std::fs::write(
7966            connect_json_path(&temp),
7967            serde_json::json!({
7968                "platforms": [
7969                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7970                ]
7971            })
7972            .to_string(),
7973        )
7974        .unwrap();
7975
7976        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7977        assert_eq!(config.connect.platforms.len(), 1);
7978        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7979        assert_eq!(
7980            config.connect.platforms[0].token.as_deref(),
7981            Some("connect-secret")
7982        );
7983        assert!(config.connect.platforms[0].token_encrypted.is_none());
7984    }
7985
7986    #[test]
7987    fn load_without_connect_json_yields_empty_inert_connect_config() {
7988        let temp = TempHome::new();
7989        temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7990
7991        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7992        assert!(
7993            config.connect.platforms.is_empty(),
7994            "no connect.json and no legacy key -> empty/inert connect config"
7995        );
7996        assert!(
7997            !connect_json_path(&temp).exists(),
7998            "load must not create connect.json when there is nothing to migrate"
7999        );
8000    }
8001
8002    #[test]
8003    fn migration_adopts_legacy_connect_key_and_writes_both_files() {
8004        let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
8005        let temp = TempHome::new();
8006        let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
8007        let legacy_cipher_for_assert = legacy_cipher.clone();
8008        // Legacy state (#453): connect lives inline in config.json, no connect.json yet.
8009        temp.set_config_json(
8010            &serde_json::json!({
8011                "http_proxy": "http://keep-me",
8012                "connect": {
8013                    "platforms": [
8014                        { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
8015                    ]
8016                }
8017            })
8018            .to_string(),
8019        );
8020
8021        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8022
8023        // In-memory: legacy value adopted.
8024        assert_eq!(config.connect.platforms.len(), 1);
8025        assert_eq!(
8026            config.connect.platforms[0].token.as_deref(),
8027            Some("legacy-connect-secret")
8028        );
8029        // An unrelated field from the same load survives the migration rewrite.
8030        assert_eq!(config.http_proxy, "http://keep-me");
8031
8032        // On disk: connect.json was created...
8033        assert!(
8034            connect_json_path(&temp).exists(),
8035            "migration proactively creates connect.json"
8036        );
8037        let connect_json: serde_json::Value =
8038            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8039                .unwrap();
8040        assert_eq!(
8041            connect_json["platforms"][0]["token_encrypted"],
8042            legacy_cipher_for_assert
8043        );
8044
8045        // ...and config.json was rewritten without the `connect` key.
8046        let config_json: serde_json::Value =
8047            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8048                .unwrap();
8049        assert!(
8050            config_json.get("connect").is_none(),
8051            "config.json is rewritten without the legacy `connect` key"
8052        );
8053    }
8054
8055    /// #457: the legacy-key migration must be a NARROW write (strip `connect`
8056    /// from config.json + write connect.json) — not the full `save_to_dir`,
8057    /// which would re-encrypt every OTHER secret in config.json and rotate a
8058    /// `config.json.bak` generation as a load-time side effect. This matters
8059    /// most for a purely READ-ONLY command (e.g. `bamboo config get`) run on a
8060    /// machine that still has the legacy `connect` key: it must not silently
8061    /// rewrite/re-encrypt unrelated secrets or spin up a backup.
8062    #[test]
8063    fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
8064        let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
8065        let temp = TempHome::new();
8066
8067        let original_api_key_encrypted =
8068            crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
8069        temp.set_config_json(
8070            &serde_json::json!({
8071                "providers": {
8072                    "openai": {
8073                        "api_key_encrypted": original_api_key_encrypted,
8074                    }
8075                },
8076                "connect": {
8077                    "platforms": [
8078                        { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
8079                    ]
8080                }
8081            })
8082            .to_string(),
8083        );
8084
8085        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8086        assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
8087
8088        // No `config.json.bak` — the narrow write does not rotate backups the
8089        // way a full `save_to_dir` would.
8090        assert!(
8091            !temp.path.join("config.json.bak").exists(),
8092            "a read-only load migrating a legacy `connect` key must not rotate \
8093             config.json backups"
8094        );
8095
8096        // The unrelated provider secret's ciphertext is byte-for-byte
8097        // unchanged — proof it was never decrypted+re-encrypted (encryption
8098        // uses a random nonce per call, so any re-encryption would change the
8099        // bytes even for the same plaintext).
8100        let config_json: serde_json::Value =
8101            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8102                .unwrap();
8103        assert_eq!(
8104            config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
8105            "an unrelated secret's ciphertext must not be touched by the connect \
8106             migration's narrow write"
8107        );
8108        assert!(
8109            config_json.get("connect").is_none(),
8110            "config.json is still rewritten without the legacy `connect` key"
8111        );
8112    }
8113
8114    #[test]
8115    fn both_files_present_connect_json_wins() {
8116        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
8117        let temp = TempHome::new();
8118        let stale = crate::encryption::encrypt("stale-secret").unwrap();
8119        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8120        temp.set_config_json(
8121            &serde_json::json!({
8122                "connect": {
8123                    "platforms": [
8124                        { "type": "telegram", "token_encrypted": stale }
8125                    ]
8126                }
8127            })
8128            .to_string(),
8129        );
8130        std::fs::write(
8131            connect_json_path(&temp),
8132            serde_json::json!({
8133                "platforms": [
8134                    { "type": "telegram", "token_encrypted": authoritative }
8135                ]
8136            })
8137            .to_string(),
8138        )
8139        .unwrap();
8140
8141        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8142        assert_eq!(
8143            config.connect.platforms[0].token.as_deref(),
8144            Some("authoritative-secret"),
8145            "connect.json wins over a stale legacy config.json key"
8146        );
8147    }
8148
8149    /// #457: when both files are present, the superseded `connect` key in
8150    /// config.json must be stripped PROACTIVELY on load — not left to linger
8151    /// until the next natural save, which spreads token ciphertext across two
8152    /// files for longer than necessary.
8153    #[test]
8154    fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
8155        let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
8156        let temp = TempHome::new();
8157        let stale = crate::encryption::encrypt("stale-secret").unwrap();
8158        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8159        temp.set_config_json(
8160            &serde_json::json!({
8161                "http_proxy": "http://keep-me",
8162                "connect": {
8163                    "platforms": [
8164                        { "type": "telegram", "token_encrypted": stale }
8165                    ]
8166                }
8167            })
8168            .to_string(),
8169        );
8170        std::fs::write(
8171            connect_json_path(&temp),
8172            serde_json::json!({
8173                "platforms": [
8174                    { "type": "telegram", "token_encrypted": authoritative }
8175                ]
8176            })
8177            .to_string(),
8178        )
8179        .unwrap();
8180
8181        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8182        assert_eq!(
8183            config.connect.platforms[0].token.as_deref(),
8184            Some("authoritative-secret")
8185        );
8186        // Unrelated field survives the strip.
8187        assert_eq!(config.http_proxy, "http://keep-me");
8188
8189        let config_json: serde_json::Value =
8190            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8191                .unwrap();
8192        assert!(
8193            config_json.get("connect").is_none(),
8194            "the stale legacy `connect` key must be stripped from config.json \
8195             immediately on load, not left for the next natural save"
8196        );
8197    }
8198
8199    /// #468 (follow-up to #457): a `.bak` generation that predates the #455
8200    /// split can still carry the legacy embedded `connect` sub-tree even
8201    /// after the LIVE config.json has long since been migrated (a clean
8202    /// config.json here, with no `connect` key at all, proves the sweep does
8203    /// not depend on the current-load migration path having just fired).
8204    /// Only the tainted generation is rewritten; every other key in it
8205    /// survives, and the rewrite strips exactly the `connect` key.
8206    #[test]
8207    fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
8208        let temp = TempHome::new();
8209        temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
8210        std::fs::write(
8211            temp.path.join("config.json.bak"),
8212            serde_json::json!({
8213                "http_proxy": "http://old",
8214                "connect": {
8215                    "platforms": [
8216                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
8217                    ]
8218                }
8219            })
8220            .to_string(),
8221        )
8222        .unwrap();
8223
8224        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8225        // The live config is unaffected — connect.json never existed and
8226        // config.json never had the key, so in-memory connect stays empty.
8227        assert!(config.connect.platforms.is_empty());
8228
8229        let bak: serde_json::Value = serde_json::from_str(
8230            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8231        )
8232        .unwrap();
8233        assert!(
8234            bak.get("connect").is_none(),
8235            "the legacy `connect` key must be stripped from the tainted .bak generation"
8236        );
8237        assert_eq!(
8238            bak["http_proxy"], "http://old",
8239            "every other key in the .bak generation survives the scrub byte-for-byte in content"
8240        );
8241    }
8242
8243    /// The sweep must touch EVERY rotated generation that carries the legacy
8244    /// key, not just `.bak` — an upgraded instance can have the taint several
8245    /// generations deep depending on how many saves happened since #455/#457
8246    /// shipped but before this fix.
8247    #[test]
8248    fn scrub_reaches_all_rotated_generations() {
8249        let temp = TempHome::new();
8250        temp.set_config_json(&serde_json::json!({}).to_string());
8251        for (gen_suffix, cipher) in [
8252            ("config.json.bak", "cipher-gen0"),
8253            ("config.json.bak.1", "cipher-gen1"),
8254            ("config.json.bak.2", "cipher-gen2"),
8255        ] {
8256            std::fs::write(
8257                temp.path.join(gen_suffix),
8258                serde_json::json!({
8259                    "connect": {
8260                        "platforms": [
8261                            { "type": "telegram", "token_encrypted": cipher }
8262                        ]
8263                    }
8264                })
8265                .to_string(),
8266            )
8267            .unwrap();
8268        }
8269
8270        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8271
8272        for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
8273            let value: serde_json::Value =
8274                serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
8275                    .unwrap();
8276            assert!(
8277                value.get("connect").is_none(),
8278                "{gen_suffix} must have its legacy `connect` key stripped"
8279            );
8280        }
8281    }
8282
8283    /// A `.bak` generation with NO legacy `connect` key must be left
8284    /// completely untouched by the sweep — not even a byte-identical
8285    /// rewrite — preserving the file's bytes/mtime exactly. This is the
8286    /// overwhelming common case (any backup created after #455/#457 shipped)
8287    /// and the whole point of the surgical, only-touch-what's-tainted
8288    /// approach: `.bak` files are the user's recovery net (#493) and
8289    /// shouldn't be churned by an unrelated sweep.
8290    #[test]
8291    fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
8292        let temp = TempHome::new();
8293        temp.set_config_json(&serde_json::json!({}).to_string());
8294        let bak_path = temp.path.join("config.json.bak");
8295        std::fs::write(
8296            &bak_path,
8297            serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
8298        )
8299        .unwrap();
8300
8301        let before_bytes = std::fs::read(&bak_path).unwrap();
8302        let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8303
8304        // A tiny sleep would make an mtime-changed assertion more robust, but
8305        // even without one, a same-mtime filesystem is the STRONGER
8306        // guarantee of "no write happened" — good enough on its own.
8307        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8308
8309        let after_bytes = std::fs::read(&bak_path).unwrap();
8310        let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8311        assert_eq!(
8312            before_bytes, after_bytes,
8313            "a .bak generation without a legacy `connect` key must not be rewritten at all"
8314        );
8315        assert_eq!(
8316            before_mtime, after_mtime,
8317            "no write means no mtime change either"
8318        );
8319    }
8320
8321    /// An unparsable `.bak` generation (corrupt/foreign content) must be
8322    /// skipped, not deleted and not guessed at — it's left exactly as found
8323    /// so an operator can inspect it by hand, matching the same fail-safe
8324    /// posture as the rest of the backup/quarantine machinery.
8325    #[test]
8326    fn scrub_skips_unparsable_backup_without_deleting_it() {
8327        let temp = TempHome::new();
8328        temp.set_config_json(&serde_json::json!({}).to_string());
8329        std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
8330
8331        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8332
8333        let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8334        assert_eq!(
8335            content, "{ not valid json",
8336            "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8337        );
8338    }
8339
8340    /// A missing generation (e.g. only `.bak` exists, no `.bak.1`/`.bak.2`
8341    /// yet) must not trip an error — it's the common case for a young
8342    /// install and the sweep should just skip straight past it.
8343    #[test]
8344    fn scrub_tolerates_missing_generations() {
8345        let temp = TempHome::new();
8346        temp.set_config_json(&serde_json::json!({}).to_string());
8347        // No .bak files at all.
8348        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8349        assert!(config.connect.platforms.is_empty());
8350        assert!(!temp.path.join("config.json.bak").exists());
8351    }
8352
8353    /// The scrub sweep must not interfere with normal backup rotation on
8354    /// subsequent saves — rotation keeps working exactly as before.
8355    #[test]
8356    fn scrub_does_not_break_backup_rotation() {
8357        let temp = TempHome::new();
8358        std::fs::write(
8359            temp.path.join("config.json"),
8360            serde_json::json!({
8361                "http_proxy": "http://proxy-v1",
8362                "connect": {
8363                    "platforms": [
8364                        { "type": "telegram", "token_encrypted": "legacy-cipher" }
8365                    ]
8366                }
8367            })
8368            .to_string(),
8369        )
8370        .unwrap();
8371        std::fs::write(
8372            temp.path.join("config.json.bak"),
8373            serde_json::json!({
8374                "http_proxy": "http://proxy-v0",
8375                "connect": {
8376                    "platforms": [
8377                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8378                    ]
8379                }
8380            })
8381            .to_string(),
8382        )
8383        .unwrap();
8384
8385        // Load triggers: migration of the live legacy key + the .bak sweep.
8386        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8387        let bak: serde_json::Value = serde_json::from_str(
8388            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8389        )
8390        .unwrap();
8391        assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8392
8393        // Rotation still works on a subsequent save: v_current -> .bak,
8394        // .bak(old) -> .bak.1.
8395        config.http_proxy = "http://proxy-v2".to_string();
8396        config.save_to_dir(temp.path.clone()).unwrap();
8397
8398        let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8399        assert!(
8400            new_bak.contains("proxy-v1"),
8401            ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8402        );
8403        let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8404        assert!(
8405            new_bak1.contains("proxy-v0"),
8406            ".bak.1 holds the scrubbed older generation after rotation"
8407        );
8408        assert!(
8409            !new_bak1.contains("legacy-bak-cipher"),
8410            "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8411             stripped secret"
8412        );
8413    }
8414
8415    #[test]
8416    fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8417        let temp = TempHome::new();
8418        temp.set_config_json("{}");
8419        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8420
8421        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8422        assert!(
8423            config.connect.platforms.is_empty(),
8424            "corrupt connect.json fails SAFE to an empty/inert connect config"
8425        );
8426
8427        let backup = connect_json_path(&temp).with_extension("json.bak");
8428        assert!(
8429            backup.exists(),
8430            "the corrupt connect.json is quarantined to connect.json.bak"
8431        );
8432        assert!(
8433            std::fs::read_to_string(backup)
8434                .unwrap()
8435                .contains("not valid json"),
8436            "the quarantined copy holds the bad content"
8437        );
8438        // #457: quarantine MOVES the corrupt file rather than copying it, so
8439        // the data dir doesn't end up with two copies of the same corrupt
8440        // content (the live `connect.json` and its `.bak`) sitting side by
8441        // side, which reads as confusing/ambiguous mid-incident.
8442        assert!(
8443            !connect_json_path(&temp).exists(),
8444            "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8445             connect.json should remain after quarantine"
8446        );
8447    }
8448
8449    #[test]
8450    fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8451        let temp = TempHome::new();
8452        // A legacy inline `connect` key is present too — it must NOT be used as a
8453        // fallback when connect.json is corrupt (security-sensitive: fail safe).
8454        temp.set_config_json(
8455            &serde_json::json!({
8456                "connect": {
8457                    "platforms": [
8458                        { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8459                    ]
8460                }
8461            })
8462            .to_string(),
8463        );
8464        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8465
8466        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8467        assert!(
8468            config.connect.platforms.is_empty(),
8469            "corrupt connect.json must not fall back to the legacy config.json copy"
8470        );
8471    }
8472
8473    #[test]
8474    fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8475        let temp = TempHome::new();
8476        let config = Config::create_default();
8477        assert!(config.connect.platforms.is_empty());
8478
8479        config
8480            .save_to_dir(temp.path.clone())
8481            .expect("save succeeds");
8482
8483        assert!(
8484            !connect_json_path(&temp).exists(),
8485            "an empty connect config with no pre-existing file must not create one"
8486        );
8487    }
8488
8489    #[test]
8490    fn connect_json_backed_up_before_overwrite() {
8491        let temp = TempHome::new();
8492        std::fs::write(
8493            connect_json_path(&temp),
8494            serde_json::json!({
8495                "platforms": [
8496                    { "type": "telegram", "token_encrypted": "old-cipher" }
8497                ]
8498            })
8499            .to_string(),
8500        )
8501        .unwrap();
8502
8503        let mut config = Config::create_default();
8504        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8505        config
8506            .save_to_dir(temp.path.clone())
8507            .expect("save succeeds");
8508
8509        let backup = connect_json_path(&temp).with_extension("json.bak");
8510        assert!(
8511            std::fs::read_to_string(backup)
8512                .unwrap()
8513                .contains("old-cipher"),
8514            "the previous connect.json is preserved as connect.json.bak before the overwrite"
8515        );
8516        let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8517        assert!(current.contains("new-cipher"));
8518    }
8519
8520    // ── Feishu adapter config fields (epic #447 phase 3, §2a) ───────────
8521
8522    #[test]
8523    fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8524        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8525        let temp = TempHome::new();
8526
8527        let mut config = Config::create_default();
8528        config.connect.platforms = vec![ConnectPlatformConfig {
8529            id: None,
8530            project_id: None,
8531            platform_type: "feishu".to_string(),
8532            token: None,
8533            token_encrypted: None,
8534            token_credential_ref: None,
8535            token_configured: false,
8536            app_id: Some("cli_real_app_id".to_string()),
8537            app_secret: Some("plain-app-secret".to_string()),
8538            app_secret_encrypted: None,
8539            app_secret_credential_ref: None,
8540            app_secret_configured: false,
8541            domain: Some("lark".to_string()),
8542            allow_from: vec!["ou_1".to_string()],
8543            admin_from: Vec::new(),
8544        }];
8545
8546        config
8547            .save_to_dir(temp.path.clone())
8548            .expect("save succeeds");
8549
8550        let connect_json: serde_json::Value =
8551            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8552                .unwrap();
8553        assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8554        assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8555        assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8556        assert!(
8557            connect_json["platforms"][0]["app_secret_encrypted"]
8558                .as_str()
8559                .is_some_and(|v| !v.is_empty()),
8560            "app_secret is persisted in its encrypted form in connect.json"
8561        );
8562        assert!(
8563            connect_json["platforms"][0].get("app_secret").is_none(),
8564            "the plaintext app_secret is never persisted (skip_serializing)"
8565        );
8566    }
8567
8568    #[test]
8569    fn load_hydrates_feishu_app_secret_from_encrypted() {
8570        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8571        let temp = TempHome::new();
8572
8573        let mut config = Config::create_default();
8574        config.connect.platforms = vec![ConnectPlatformConfig {
8575            id: None,
8576            project_id: None,
8577            platform_type: "feishu".to_string(),
8578            token: None,
8579            token_encrypted: None,
8580            token_credential_ref: None,
8581            token_configured: false,
8582            app_id: Some("cli_real_app_id".to_string()),
8583            app_secret: Some("plain-app-secret".to_string()),
8584            app_secret_encrypted: None,
8585            app_secret_credential_ref: None,
8586            app_secret_configured: false,
8587            domain: Some("lark".to_string()),
8588            allow_from: vec!["ou_1".to_string()],
8589            admin_from: Vec::new(),
8590        }];
8591        config
8592            .save_to_dir(temp.path.clone())
8593            .expect("save succeeds");
8594
8595        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8596        assert_eq!(reloaded.connect.platforms.len(), 1);
8597        assert_eq!(
8598            reloaded.connect.platforms[0].app_secret.as_deref(),
8599            Some("plain-app-secret"),
8600            "reload hydrates app_secret from app_secret_encrypted"
8601        );
8602        assert_eq!(
8603            reloaded.connect.platforms[0].app_id.as_deref(),
8604            Some("cli_real_app_id")
8605        );
8606        assert_eq!(
8607            reloaded.connect.platforms[0].domain.as_deref(),
8608            Some("lark")
8609        );
8610    }
8611
8612    #[test]
8613    fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8614        let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8615        let temp = TempHome::new();
8616        let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8617        std::fs::write(
8618            connect_json_path(&temp),
8619            serde_json::json!({
8620                "platforms": [
8621                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8622                ]
8623            })
8624            .to_string(),
8625        )
8626        .unwrap();
8627
8628        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8629
8630        assert_eq!(config.connect.platforms.len(), 1);
8631        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8632        assert_eq!(
8633            config.connect.platforms[0].token.as_deref(),
8634            Some("legacy-telegram-secret")
8635        );
8636        assert_eq!(
8637            config.connect.platforms[0].app_id, None,
8638            "a legacy entry with no Feishu fields deserializes them as None"
8639        );
8640        assert_eq!(config.connect.platforms[0].app_secret, None);
8641        assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8642        assert_eq!(config.connect.platforms[0].domain, None);
8643    }
8644
8645    #[test]
8646    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8647        let _lock = env_lock_acquire();
8648        let temp_home = TempHome::new();
8649        temp_home.set_config_json(
8650            r#"{
8651  "provider": "openai",
8652  "providers": {
8653    "openai": {
8654      "api_key": "sk-test",
8655      "model": "gpt-4o"
8656    }
8657  }
8658}"#,
8659        );
8660
8661        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8662        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8663
8664        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8665
8666        assert_eq!(
8667            config
8668                .providers
8669                .openai
8670                .as_ref()
8671                .and_then(|c| c.model.as_deref()),
8672            Some("gpt-4o")
8673        );
8674        assert!(
8675            config.http_proxy.is_empty(),
8676            "config should keep http_proxy empty when field is omitted"
8677        );
8678        assert!(
8679            config.https_proxy.is_empty(),
8680            "config should keep https_proxy empty when field is omitted"
8681        );
8682    }
8683
8684    #[test]
8685    fn get_memory_background_model_prefers_memory_specific_override() {
8686        let mut config = Config::default();
8687        config.features.provider_model_ref = false;
8688        config.provider = "openai".to_string();
8689        config.providers.openai = Some(OpenAIConfig {
8690            api_key: "test".to_string(),
8691            api_key_encrypted: None,
8692            credential_ref: None,
8693            base_url: None,
8694            model: Some("gpt-main".to_string()),
8695            fast_model: Some("gpt-fast".to_string()),
8696            vision_model: None,
8697            reasoning_effort: None,
8698            responses_only_models: vec![],
8699            request_overrides: None,
8700            extra: BTreeMap::new(),
8701            api_key_from_env: false,
8702        });
8703        config.memory.0 = Some(MemoryConfig {
8704            background_model: Some("memory-fast".to_string()),
8705            ..MemoryConfig::default()
8706        });
8707
8708        assert_eq!(
8709            config.get_memory_background_model().as_deref(),
8710            Some("memory-fast")
8711        );
8712    }
8713
8714    #[test]
8715    fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8716        // #373: the settings-PATCH serde round-trip drops every provider's
8717        // skip_serializing api_key; an env-sourced key (no ciphertext) can't be
8718        // re-hydrated, so it must be copied back from the live `current` config —
8719        // but an explicitly re-set key and non-env keys must NOT be touched.
8720        let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8721            api_key: api_key.to_string(),
8722            api_key_encrypted: None,
8723            credential_ref: None,
8724            base_url: None,
8725            model: None,
8726            fast_model: None,
8727            vision_model: None,
8728            reasoning_effort: None,
8729            responses_only_models: vec![],
8730            request_overrides: None,
8731            extra: BTreeMap::new(),
8732            api_key_from_env: from_env,
8733        };
8734
8735        // Env-sourced key dropped by the round-trip → restored.
8736        let mut current = Config::default();
8737        current.providers.openai = Some(openai("sk-env", true));
8738        let mut merged = Config::default();
8739        merged.providers.openai = Some(openai("", false)); // post-round-trip
8740        merged.preserve_env_sourced_provider_keys(&current);
8741        let got = merged.providers.openai.as_ref().unwrap();
8742        assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8743        assert!(got.api_key_from_env, "env flag restored");
8744
8745        // A key explicitly re-set by the patch is NOT overridden.
8746        let mut merged = Config::default();
8747        merged.providers.openai = Some(openai("sk-explicit", false));
8748        merged.preserve_env_sourced_provider_keys(&current);
8749        assert_eq!(
8750            merged.providers.openai.as_ref().unwrap().api_key,
8751            "sk-explicit",
8752            "explicit patch key must win"
8753        );
8754
8755        // A non-env key in current is NOT restored here (that's ciphertext hydration's job).
8756        let mut current_plain = Config::default();
8757        current_plain.providers.openai = Some(openai("sk-plain", false));
8758        let mut merged = Config::default();
8759        merged.providers.openai = Some(openai("", false));
8760        merged.preserve_env_sourced_provider_keys(&current_plain);
8761        assert!(
8762            merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8763            "non-env key must not be restored by this path"
8764        );
8765    }
8766
8767    #[test]
8768    fn refresh_preserves_ciphertext_when_plaintext_empty() {
8769        // #268: a provider whose stored ciphertext failed to decrypt at hydration
8770        // has an empty in-memory api_key. An unrelated later save must NOT null its
8771        // ciphertext — that would permanently drop a key the user never touched.
8772        let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8773            api_key: api_key.to_string(),
8774            api_key_encrypted: enc.map(str::to_string),
8775            credential_ref: None,
8776            base_url: None,
8777            model: None,
8778            fast_model: None,
8779            vision_model: None,
8780            reasoning_effort: None,
8781            responses_only_models: vec![],
8782            request_overrides: None,
8783            extra: BTreeMap::new(),
8784            api_key_from_env: false,
8785        };
8786
8787        // Empty plaintext + existing ciphertext → ciphertext preserved (the bug).
8788        let mut config = Config::default();
8789        config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8790        config
8791            .refresh_provider_api_keys_encrypted()
8792            .expect("refresh");
8793        assert_eq!(
8794            config
8795                .providers
8796                .openai
8797                .as_ref()
8798                .unwrap()
8799                .api_key_encrypted
8800                .as_deref(),
8801            Some("preexisting-ciphertext"),
8802            "existing ciphertext must be preserved when plaintext is empty"
8803        );
8804
8805        // Empty plaintext + no ciphertext → stays None (nothing to preserve).
8806        let mut config = Config::default();
8807        config.providers.openai = Some(openai("", None));
8808        config
8809            .refresh_provider_api_keys_encrypted()
8810            .expect("refresh");
8811        assert!(
8812            config
8813                .providers
8814                .openai
8815                .as_ref()
8816                .unwrap()
8817                .api_key_encrypted
8818                .is_none(),
8819            "no key + no ciphertext should stay None"
8820        );
8821
8822        // Non-empty plaintext → (re)encrypted to a fresh, non-empty ciphertext.
8823        let mut config = Config::default();
8824        config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8825        config
8826            .refresh_provider_api_keys_encrypted()
8827            .expect("refresh");
8828        let enc = config
8829            .providers
8830            .openai
8831            .as_ref()
8832            .unwrap()
8833            .api_key_encrypted
8834            .clone()
8835            .expect("ciphertext present");
8836        assert!(
8837            !enc.is_empty() && enc != "stale-ciphertext",
8838            "plaintext re-encrypted"
8839        );
8840    }
8841
8842    #[test]
8843    fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8844        // #516: `save_to_dir` refreshes ciphertext only on its save-time clone,
8845        // so a provider instance created over HTTP stays plaintext-only in the
8846        // live config. Serializing that live config (as the settings-PATCH
8847        // merge does) drops the `skip_serializing` plaintext and the key is
8848        // gone. `refresh_encrypted_secrets` on the live config closes the gap.
8849        let mut config = Config::default();
8850        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8851            "provider_type": "openai",
8852            "api_key": "sk-instance-live",
8853        }))
8854        .expect("valid instance");
8855        config
8856            .provider_instances
8857            .insert("work".to_string(), instance);
8858
8859        config.refresh_encrypted_secrets().expect("refresh");
8860        assert!(
8861            config.provider_instances["work"]
8862                .api_key_encrypted
8863                .is_some(),
8864            "live config must hold ciphertext after refresh"
8865        );
8866
8867        // The build_merged_config-style round-trip.
8868        let value = serde_json::to_value(&config).expect("serialize");
8869        let mut back: Config = serde_json::from_value(value).expect("deserialize");
8870        assert!(
8871            back.provider_instances["work"].api_key.is_empty(),
8872            "plaintext is skip_serializing"
8873        );
8874        back.hydrate_provider_instance_api_keys_from_encrypted();
8875        assert_eq!(
8876            back.provider_instances["work"].api_key, "sk-instance-live",
8877            "key must be recoverable from the round-tripped ciphertext"
8878        );
8879    }
8880
8881    #[test]
8882    fn get_memory_background_model_falls_back_to_provider_fast_model() {
8883        let mut config = Config::default();
8884        config.features.provider_model_ref = false;
8885        config.provider = "openai".to_string();
8886        config.providers.openai = Some(OpenAIConfig {
8887            api_key: "test".to_string(),
8888            api_key_encrypted: None,
8889            credential_ref: None,
8890            base_url: None,
8891            model: Some("gpt-main".to_string()),
8892            fast_model: Some("gpt-fast".to_string()),
8893            vision_model: None,
8894            reasoning_effort: None,
8895            responses_only_models: vec![],
8896            request_overrides: None,
8897            extra: BTreeMap::new(),
8898            api_key_from_env: false,
8899        });
8900
8901        assert_eq!(
8902            config.get_memory_background_model().as_deref(),
8903            Some("gpt-fast")
8904        );
8905    }
8906
8907    #[test]
8908    fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8909        let mut config = Config::default();
8910        config.features.provider_model_ref = false;
8911        config.provider = "openai".to_string();
8912        config.providers.openai = Some(OpenAIConfig {
8913            api_key: "sk-stale".to_string(),
8914            model: Some("legacy-main".to_string()),
8915            fast_model: Some("legacy-fast".to_string()),
8916            vision_model: Some("legacy-vision".to_string()),
8917            reasoning_effort: Some(ReasoningEffort::Low),
8918            ..OpenAIConfig::default()
8919        });
8920        let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8921            "provider_type": "openai",
8922            "model": "instance-main",
8923            "fast_model": "instance-fast",
8924            "vision_model": "instance-vision",
8925            "reasoning_effort": "high",
8926            "enabled": true
8927        }))
8928        .unwrap();
8929        instance.api_key = "sk-instance".to_string();
8930        config
8931            .provider_instances
8932            .insert("work".to_string(), instance);
8933        config.default_provider_instance = Some("work".to_string());
8934
8935        assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8936        assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8937        assert_eq!(
8938            config.get_memory_background_model().as_deref(),
8939            Some("instance-fast")
8940        );
8941        assert_eq!(
8942            config.get_task_summary_model().as_deref(),
8943            Some("instance-fast")
8944        );
8945        assert_eq!(
8946            config.get_vision_model().as_deref(),
8947            Some("instance-vision")
8948        );
8949        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8950    }
8951
8952    #[test]
8953    fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8954        let mut config = Config::default();
8955        config.features.provider_model_ref = true;
8956        config.provider_instances.insert(
8957            "compatibility-default".to_string(),
8958            serde_json::from_value(serde_json::json!({
8959                "provider_type": "openai",
8960                "reasoning_effort": "low",
8961                "enabled": true
8962            }))
8963            .unwrap(),
8964        );
8965        config.provider_instances.insert(
8966            "chat-provider".to_string(),
8967            serde_json::from_value(serde_json::json!({
8968                "provider_type": "anthropic",
8969                "reasoning_effort": "high",
8970                "enabled": true
8971            }))
8972            .unwrap(),
8973        );
8974        config.default_provider_instance = Some("compatibility-default".to_string());
8975        config.defaults = Some(
8976            serde_json::from_value(serde_json::json!({
8977                "chat": { "provider": "chat-provider", "model": "claude" }
8978            }))
8979            .unwrap(),
8980        );
8981
8982        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8983
8984        config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8985        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8986    }
8987
8988    #[test]
8989    fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8990        let _provider =
8991            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8992        let _openai_key = crate::test_support::override_runtime_env_var(
8993            "BAMBOO_OPENAI_API_KEY",
8994            Some("sk-runtime-env-instance"),
8995        );
8996        let _anthropic_key =
8997            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8998        let _gemini_key =
8999            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9000
9001        let mut config = Config::default();
9002        *config.providers_mut() = ProviderConfigs::default();
9003        for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
9004            let mut extra = serde_json::Map::new();
9005            if from_environment {
9006                extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
9007            }
9008            extra.insert("provider_type".to_string(), serde_json::json!("openai"));
9009            extra.insert("enabled".to_string(), serde_json::json!(true));
9010            config.provider_instances.insert(
9011                id.to_string(),
9012                serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
9013            );
9014        }
9015
9016        config.apply_runtime_env_overrides();
9017
9018        assert_eq!(
9019            config.default_provider_instance.as_deref(),
9020            Some("a-marked"),
9021            "type override selects the lexicographically first enabled instance"
9022        );
9023        assert_eq!(
9024            config.provider_instances["a-marked"].api_key,
9025            "sk-runtime-env-instance"
9026        );
9027        assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
9028        assert!(
9029            config.providers().openai.is_none(),
9030            "instance-mode env hydration must not recreate a legacy alias"
9031        );
9032
9033        config.refresh_encrypted_secrets().unwrap();
9034        let serialized = serde_json::to_string(&config).unwrap();
9035        assert!(!serialized.contains("sk-runtime-env-instance"));
9036        assert!(
9037            config.provider_instances["a-marked"]
9038                .api_key_encrypted
9039                .is_none(),
9040            "runtime env plaintext must not be encrypted into ordinary config"
9041        );
9042    }
9043
9044    #[test]
9045    fn runtime_provider_override_prefers_exact_instance_id() {
9046        let _provider =
9047            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
9048        let _openai_key =
9049            crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
9050        let _anthropic_key =
9051            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
9052        let _gemini_key =
9053            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9054        let mut config = Config::default();
9055        for id in ["work", "personal"] {
9056            config.provider_instances.insert(
9057                id.to_string(),
9058                serde_json::from_value(serde_json::json!({
9059                    "provider_type": "openai",
9060                    "enabled": true
9061                }))
9062                .unwrap(),
9063            );
9064        }
9065
9066        config.apply_runtime_env_overrides();
9067        assert_eq!(
9068            config.default_provider_instance.as_deref(),
9069            Some("personal")
9070        );
9071    }
9072
9073    #[test]
9074    fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
9075        let mut config = Config::default();
9076        config.features.provider_model_ref = false;
9077        config.provider = "openai".to_string();
9078        config.providers.openai = Some(OpenAIConfig {
9079            api_key: "test".to_string(),
9080            api_key_encrypted: None,
9081            credential_ref: None,
9082            base_url: None,
9083            model: Some("gpt-main".to_string()),
9084            fast_model: None,
9085            vision_model: None,
9086            reasoning_effort: None,
9087            responses_only_models: vec![],
9088            request_overrides: None,
9089            extra: BTreeMap::new(),
9090            api_key_from_env: false,
9091        });
9092
9093        assert_eq!(
9094            config.get_memory_background_model().as_deref(),
9095            Some("gpt-main")
9096        );
9097    }
9098
9099    #[test]
9100    fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
9101        let legacy = serde_json::json!({
9102            "memory": MemoryConfig {
9103                background_model: Some("dream-fast".to_string()),
9104                summary_target_ratio: 0.20,
9105                summary_safe_window_percent: 80,
9106                auto_dream_enabled: true,
9107                auto_dream_interval_secs: 900,
9108                project_prompt_injection: false,
9109                relevant_recall: false,
9110                relevant_recall_rerank: true,
9111                project_first_dream: false,
9112                ledger_agenda_injection: false,
9113                ledger_gardener_enabled: false,
9114                ledger_gardener_interval_secs: 7_200,
9115                ledger_distillation_enabled: false,
9116                dream_refine_mode: true,
9117                gardener_enabled: true,
9118                gardener_interval_secs: 3_600,
9119                gardener_volume_trigger: 40,
9120                gardener_max_splits_per_run: 4,
9121                gardener_min_sections: 7,
9122                dedup_gardener_enabled: true,
9123                dedup_gardener_min_score: 0.7,
9124                dedup_gardener_max_merges_per_run: 3,
9125                memory_active_capacity: 500,
9126                capacity_max_archivals_per_run: 10,
9127                granularity_freshness_gardener_enabled: false,
9128            }
9129        });
9130        let config: Config = serde_json::from_value(legacy).unwrap();
9131
9132        let serialized = serde_json::to_value(&config).expect("config should serialize");
9133        assert!(serialized.get("memory").is_some());
9134        let round_tripped: Config = serde_json::from_value(serialized).unwrap();
9135        assert!(round_tripped
9136            .memory()
9137            .as_ref()
9138            .is_some_and(|memory| memory.dream_refine_mode));
9139        let memory = config.memory.as_ref().expect("memory config should exist");
9140        assert!(memory.auto_dream_enabled);
9141        assert!(!memory.project_prompt_injection);
9142        assert!(!memory.relevant_recall);
9143        assert!(memory.relevant_recall_rerank);
9144        assert!(!memory.project_first_dream);
9145        assert!(memory.dream_refine_mode);
9146        assert!(memory.gardener_enabled);
9147        assert_eq!(memory.gardener_interval_secs, 3_600);
9148        assert_eq!(memory.gardener_volume_trigger, 40);
9149        assert_eq!(memory.gardener_max_splits_per_run, 4);
9150        assert_eq!(memory.gardener_min_sections, 7);
9151        assert!(memory.dedup_gardener_enabled);
9152        assert_eq!(memory.dedup_gardener_min_score, 0.7);
9153        assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
9154        assert_eq!(memory.memory_active_capacity, 500);
9155        assert_eq!(memory.capacity_max_archivals_per_run, 10);
9156        assert!(!memory.granularity_freshness_gardener_enabled);
9157    }
9158
9159    /// L5: capacity is OFF by default (0 = unbounded) — an opt-in feature.
9160    #[test]
9161    fn memory_active_capacity_defaults_off() {
9162        assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
9163        assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
9164        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9165        let memory = parsed.memory.as_ref().unwrap();
9166        assert_eq!(memory.memory_active_capacity, 0);
9167        assert_eq!(
9168            memory.capacity_max_archivals_per_run, 50,
9169            "omitted field takes the serde default fn"
9170        );
9171    }
9172
9173    #[test]
9174    fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
9175        let defaults = MemoryConfig::default();
9176        assert_eq!(defaults.summary_target_ratio, 0.20);
9177        assert_eq!(defaults.summary_safe_window_percent, 80);
9178
9179        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9180        let memory = parsed.memory.as_ref().expect("memory present");
9181        assert_eq!(memory.summary_target_ratio, 0.20);
9182        assert_eq!(memory.summary_safe_window_percent, 80);
9183    }
9184
9185    /// L4: the maintenance integrators are ON by default — both via
9186    /// `MemoryConfig::default()` AND when a config file omits the flags entirely
9187    /// (serde `default = fn`, not the bare `#[serde(default)]` = `false`).
9188    #[test]
9189    fn memory_maintenance_integrators_default_on() {
9190        let defaults = MemoryConfig::default();
9191        assert!(defaults.auto_dream_enabled);
9192        assert!(defaults.gardener_enabled);
9193        assert!(defaults.dedup_gardener_enabled);
9194        assert_eq!(defaults.gardener_volume_trigger, 25);
9195
9196        // A config that mentions `memory` but omits the flags must still be ON.
9197        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9198        let memory = parsed.memory.as_ref().expect("memory present");
9199        assert!(
9200            memory.auto_dream_enabled,
9201            "auto_dream on when field omitted"
9202        );
9203        assert!(memory.gardener_enabled, "gardener on when field omitted");
9204        assert!(
9205            memory.dedup_gardener_enabled,
9206            "dedup gardener on when field omitted"
9207        );
9208        // An explicit opt-out is still honored.
9209        let opted_out: Config =
9210            serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
9211        assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
9212    }
9213
9214    #[test]
9215    fn memory_config_env_overrides_prompt_flags() {
9216        let _lock = env_lock_acquire();
9217        let temp_home = TempHome::new();
9218        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9219        let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
9220        let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
9221        let _relevant_recall_rerank =
9222            EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
9223        let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
9224
9225        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9226        let memory = config
9227            .memory
9228            .as_ref()
9229            .expect("memory config should be created by env overrides");
9230        assert!(!memory.project_prompt_injection);
9231        assert!(!memory.relevant_recall);
9232        assert!(memory.relevant_recall_rerank);
9233        assert!(!memory.project_first_dream);
9234    }
9235
9236    #[test]
9237    fn provider_api_keys_injected_from_env_and_never_persisted() {
9238        let _lock = env_lock_acquire();
9239        let temp_home = TempHome::new();
9240        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9241        let _anthropic = crate::test_support::override_runtime_env_var(
9242            "BAMBOO_ANTHROPIC_API_KEY",
9243            Some("sk-ant-from-env"),
9244        );
9245        let _openai = crate::test_support::override_runtime_env_var(
9246            "BAMBOO_OPENAI_API_KEY",
9247            Some("sk-oai-from-env"),
9248        );
9249        let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9250
9251        // No config.json on disk → the providers are created from the env keys
9252        // alone (#253: deploy without a plaintext api_key in a mounted file).
9253        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9254        assert_eq!(
9255            config
9256                .providers
9257                .anthropic
9258                .as_ref()
9259                .expect("anthropic created from env")
9260                .api_key,
9261            "sk-ant-from-env"
9262        );
9263        assert_eq!(
9264            config
9265                .providers
9266                .openai
9267                .as_ref()
9268                .expect("openai created from env")
9269                .api_key,
9270            "sk-oai-from-env"
9271        );
9272        // An unset provider is not fabricated.
9273        assert!(config.providers.gemini.is_none());
9274
9275        // The real "never persisted" guarantee: saving the config must NOT bake
9276        // the env key into config.json — not as plaintext AND not re-encrypted
9277        // into `api_key_encrypted` (which save's `refresh_provider_api_keys_encrypted`
9278        // would otherwise do). This is what actually happens on the server when
9279        // any unrelated setting is saved / on a fabric-reconcile boot.
9280        config
9281            .save_to_dir(temp_home.path.clone())
9282            .expect("save config");
9283        let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
9284            .expect("read persisted config.json");
9285        assert!(
9286            !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
9287            "env key must not be persisted as plaintext"
9288        );
9289        let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
9290        assert!(
9291            disk_json["providers"]["anthropic"]
9292                .get("api_key_encrypted")
9293                .is_none(),
9294            "env-sourced anthropic key must not be re-encrypted into config.json"
9295        );
9296        assert!(
9297            disk_json["providers"]["openai"]
9298                .get("api_key_encrypted")
9299                .is_none(),
9300            "env-sourced openai key must not be re-encrypted into config.json"
9301        );
9302
9303        // And once the env vars are gone, a reload from that same dir has no key
9304        // (nothing was persisted).
9305        drop(_anthropic);
9306        drop(_openai);
9307        let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
9308        assert!(reloaded
9309            .providers
9310            .anthropic
9311            .as_ref()
9312            .map(|a| a.api_key.is_empty())
9313            .unwrap_or(true));
9314    }
9315
9316    #[test]
9317    fn get_default_work_area_path_expands_tilde_and_requires_directory() {
9318        let _lock = env_lock_acquire();
9319        let temp_home = TempHome::new();
9320        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9321        let target = temp_home.path.join("workspace-default");
9322        std::fs::create_dir_all(&target).expect("default work area dir should exist");
9323
9324        let mut config = Config::default();
9325        config.default_work_area.replace(DefaultWorkAreaConfig {
9326            path: Some("~/workspace-default".to_string()),
9327        });
9328
9329        assert_eq!(config.get_default_work_area_path(), Some(target));
9330    }
9331
9332    #[test]
9333    fn get_default_work_area_path_returns_none_for_missing_directory() {
9334        let _lock = env_lock_acquire();
9335        let temp_home = TempHome::new();
9336        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9337
9338        let mut config = Config::default();
9339        config.default_work_area.replace(DefaultWorkAreaConfig {
9340            path: Some("~/missing-default-work-area".to_string()),
9341        });
9342
9343        assert!(config.get_default_work_area_path().is_none());
9344    }
9345
9346    #[test]
9347    fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9348        let mut config = Config::default();
9349        config.tools.disabled = vec![
9350            "  read_file  ".to_string(),
9351            "".to_string(),
9352            "read_file".to_string(),
9353            "bash".to_string(),
9354            "default::getCurrentDir".to_string(),
9355            "default::applyPatch".to_string(),
9356            "default::custom_tool".to_string(),
9357            "mcp__alpha__inspect".to_string(),
9358        ];
9359
9360        config.normalize_tool_settings();
9361
9362        assert_eq!(
9363            config.tools.disabled,
9364            vec![
9365                "bash",
9366                "default::applyPatch",
9367                "default::custom_tool",
9368                "default::getCurrentDir",
9369                "mcp__alpha__inspect",
9370                "read_file"
9371            ]
9372        );
9373    }
9374
9375    #[test]
9376    fn config_load_preserves_disabled_references_for_catalog_resolution() {
9377        let _lock = env_lock_acquire();
9378        let temp_home = TempHome::new();
9379        temp_home.set_config_json(
9380            r#"{
9381  "tools": {
9382    "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9383  }
9384}"#,
9385        );
9386
9387        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9388        assert_eq!(
9389            config.tools.disabled,
9390            vec!["bash", "default::getCurrentDir", "read_file"]
9391        );
9392        assert!(config.disabled_tool_references().contains("bash"));
9393        assert!(config.disabled_tool_references().contains("read_file"));
9394        assert!(config
9395            .disabled_tool_references()
9396            .contains("default::getCurrentDir"));
9397        assert_eq!(
9398            config.disabled_tool_names(),
9399            BTreeSet::from([
9400                "Bash".to_string(),
9401                "GetCurrentDir".to_string(),
9402                "Read".to_string()
9403            ])
9404        );
9405    }
9406
9407    #[test]
9408    fn normalize_skill_settings_trims_dedupes_and_sorts() {
9409        let mut config = Config::default();
9410        config.skills.disabled = vec![
9411            " pdf ".to_string(),
9412            "".to_string(),
9413            "pdf".to_string(),
9414            "skill-creator".to_string(),
9415        ];
9416
9417        config.normalize_skill_settings();
9418
9419        assert_eq!(
9420            config.skills.disabled,
9421            vec!["pdf".to_string(), "skill-creator".to_string()]
9422        );
9423    }
9424
9425    #[test]
9426    fn config_load_reads_disabled_skills_as_normalized_ids() {
9427        let _lock = env_lock_acquire();
9428        let temp_home = TempHome::new();
9429        temp_home.set_config_json(
9430            r#"{
9431  "skills": {
9432    "disabled": [" pdf ", "skill-creator", "pdf", ""]
9433  }
9434}"#,
9435        );
9436
9437        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9438        assert_eq!(
9439            config.skills.disabled,
9440            vec!["pdf".to_string(), "skill-creator".to_string()]
9441        );
9442        assert!(config.disabled_skill_ids().contains("pdf"));
9443        assert!(config.disabled_skill_ids().contains("skill-creator"));
9444    }
9445
9446    #[test]
9447    fn test_server_config_defaults() {
9448        let _lock = env_lock_acquire();
9449        let temp_home = TempHome::new();
9450
9451        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9452        assert_eq!(config.server.port, 9562);
9453        assert_eq!(config.server.bind, "127.0.0.1");
9454        assert_eq!(config.server.workers, 10);
9455        assert!(config.server.static_dir.is_none());
9456    }
9457
9458    #[test]
9459    fn test_server_addr() {
9460        let mut config = Config::default();
9461        config.server.port = 9000;
9462        config.server.bind = "0.0.0.0".to_string();
9463        assert_eq!(config.server_addr(), "0.0.0.0:9000");
9464    }
9465
9466    #[test]
9467    fn test_env_var_overrides() {
9468        let _lock = env_lock_acquire();
9469        let temp_home = TempHome::new();
9470
9471        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9472        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9473        let _provider =
9474            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9475
9476        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9477        assert_eq!(config.server.port, 9999);
9478        assert_eq!(config.server.bind, "192.168.1.1");
9479        assert_eq!(config.provider, "openai");
9480    }
9481
9482    #[test]
9483    fn test_config_save_and_load() {
9484        let _lock = env_lock_acquire();
9485        let temp_home = TempHome::new();
9486
9487        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9488        config.server.port = 9000;
9489        config.server.bind = "0.0.0.0".to_string();
9490        config.provider = "anthropic".to_string();
9491
9492        // Save
9493        config
9494            .save_to_dir(temp_home.path.clone())
9495            .expect("Failed to save config");
9496
9497        // Load again
9498        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9499
9500        // Verify
9501        assert_eq!(loaded.server.port, 9000);
9502        assert_eq!(loaded.server.bind, "0.0.0.0");
9503        assert_eq!(loaded.provider, "anthropic");
9504    }
9505
9506    #[test]
9507    fn modular_root_and_public_serde_preserve_legacy_shape() {
9508        let _lock = env_lock_acquire();
9509        let temp_home = TempHome::new();
9510        let input = serde_json::json!({
9511            "http_proxy": "http://proxy.example",
9512            "provider": "openai",
9513            "mcp": { "servers": [] },
9514            "future_extension": { "enabled": true },
9515            "memory": { "background_model": "memory-model" },
9516            "subagents": { "max_concurrent": 7 },
9517            "providers": { "openai": { "model": "chat-model" } }
9518        });
9519
9520        let config: Config = serde_json::from_value(input).unwrap();
9521        let public = serde_json::to_value(&config).unwrap();
9522        assert_eq!(public["http_proxy"], "http://proxy.example");
9523        assert!(public.get("mcp").is_none());
9524        assert!(public.get("mcpServers").is_some());
9525        assert_eq!(public["future_extension"]["enabled"], true);
9526        assert_eq!(public["memory"]["background_model"], "memory-model");
9527        assert_eq!(public["subagents"]["max_concurrent"], 7);
9528        assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9529
9530        let round_tripped: Config = serde_json::from_value(public).unwrap();
9531        assert_eq!(
9532            round_tripped
9533                .memory()
9534                .as_ref()
9535                .unwrap()
9536                .background_model
9537                .as_deref(),
9538            Some("memory-model")
9539        );
9540        assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9541        assert_eq!(
9542            round_tripped
9543                .providers()
9544                .openai
9545                .as_ref()
9546                .unwrap()
9547                .model
9548                .as_deref(),
9549            Some("chat-model")
9550        );
9551        assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9552
9553        round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9554        let persisted: Value =
9555            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9556                .unwrap();
9557        assert_eq!(persisted["http_proxy"], "http://proxy.example");
9558        assert!(persisted.get("mcpServers").is_some());
9559        assert_eq!(persisted["future_extension"]["enabled"], true);
9560        assert!(persisted.get("memory").is_none());
9561        assert!(persisted.get("subagents").is_none());
9562        assert!(persisted.get("providers").is_none());
9563        for internal_section_name in [
9564            "network",
9565            "provider_routing",
9566            "model_behavior",
9567            "tooling",
9568            "workspace",
9569            "execution",
9570            "integrations",
9571            "plugin_security",
9572        ] {
9573            assert!(persisted.get(internal_section_name).is_none());
9574        }
9575    }
9576
9577    #[test]
9578    fn config_decrypts_proxy_auth_from_encrypted_field() {
9579        let _lock = env_lock_acquire();
9580        let temp_home = TempHome::new();
9581
9582        // Use a stable encryption key so this test doesn't depend on host identifiers.
9583        let key_guard = crate::encryption::set_test_encryption_key([
9584            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9585            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9586            0x1c, 0x1d, 0x1e, 0x1f,
9587        ]);
9588
9589        let auth = ProxyAuth {
9590            username: "user".to_string(),
9591            password: "pass".to_string(),
9592        };
9593        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9594        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9595
9596        temp_home.set_config_json(&format!(
9597            r#"{{
9598  "http_proxy": "http://proxy.example.com:8080",
9599  "proxy_auth_encrypted": "{encrypted}"
9600}}"#
9601        ));
9602        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9603        let loaded_auth = config
9604            .proxy_auth
9605            .as_ref()
9606            .expect("proxy auth should be hydrated");
9607        assert_eq!(loaded_auth.username, "user");
9608        assert_eq!(loaded_auth.password, "pass");
9609        drop(key_guard);
9610    }
9611
9612    #[test]
9613    fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9614        let _lock = env_lock_acquire();
9615        let temp_home = TempHome::new();
9616
9617        // Use a stable encryption key so this test doesn't depend on host identifiers.
9618        let key_guard = crate::encryption::set_test_encryption_key([
9619            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9620            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9621            0x1c, 0x1d, 0x1e, 0x1f,
9622        ]);
9623
9624        let auth = ProxyAuth {
9625            username: "user".to_string(),
9626            password: "pass".to_string(),
9627        };
9628        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9629        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9630
9631        // Simulate older Bodhi/Tauri persisted config keys.
9632        temp_home.set_config_json(&format!(
9633            r#"{{
9634  "http_proxy": "http://proxy.example.com:8080",
9635  "http_proxy_auth_encrypted": "{encrypted}",
9636  "https_proxy_auth_encrypted": "{encrypted}"
9637}}"#
9638        ));
9639
9640        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9641        let loaded_auth = config
9642            .proxy_auth
9643            .as_ref()
9644            .expect("proxy auth should be hydrated");
9645        assert_eq!(loaded_auth.username, "user");
9646        assert_eq!(loaded_auth.password, "pass");
9647        drop(key_guard);
9648    }
9649
9650    #[test]
9651    fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9652        let _lock = env_lock_acquire();
9653        let temp_home = TempHome::new();
9654
9655        // Use a stable encryption key so this test doesn't depend on host identifiers.
9656        let key_guard = crate::encryption::set_test_encryption_key([
9657            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9658            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9659            0x1c, 0x1d, 0x1e, 0x1f,
9660        ]);
9661
9662        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9663        config.proxy_auth = Some(ProxyAuth {
9664            username: "user".to_string(),
9665            password: "pass".to_string(),
9666        });
9667        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9668        assert!(error
9669            .to_string()
9670            .contains("isolated credential transaction"));
9671        let path = temp_home.path.join("config.json");
9672        assert!(
9673            !path.exists(),
9674            "a rejected unisolated secret must not create config.json"
9675        );
9676        drop(key_guard);
9677    }
9678
9679    #[test]
9680    fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9681        let _lock = env_lock_acquire();
9682        let temp_home = TempHome::new();
9683        let mut config = Config::default();
9684        config.env_vars.push(EnvVarEntry {
9685            name: "TOKEN".to_string(),
9686            value: String::new(),
9687            secret: true,
9688            value_encrypted: None,
9689            credential_ref: None,
9690            configured: true,
9691            description: None,
9692        });
9693        let path = temp_home.path.join("config.json");
9694
9695        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9696        assert!(error
9697            .to_string()
9698            .contains("isolated credential transaction"));
9699        assert!(
9700            !path.exists(),
9701            "a rejected dangling ref must not create config.json"
9702        );
9703
9704        let original = br#"{"preserve":"original"}"#;
9705        std::fs::write(&path, original).unwrap();
9706        config.save_to_dir(temp_home.path.clone()).unwrap_err();
9707        assert_eq!(
9708            std::fs::read(&path).unwrap(),
9709            original,
9710            "a rejected dangling ref must not modify an existing config.json"
9711        );
9712    }
9713
9714    #[test]
9715    fn config_save_persists_provider_reference_and_isolates_plaintext() {
9716        let _lock = env_lock_acquire();
9717        let temp_home = TempHome::new();
9718
9719        // Use a stable encryption key so this test doesn't depend on host identifiers.
9720        let key_guard = crate::encryption::set_test_encryption_key([
9721            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9722            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9723            0x1c, 0x1d, 0x1e, 0x1f,
9724        ]);
9725
9726        let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9727        crate::CredentialStore::open(&temp_home.path)
9728            .replace(
9729                reference.clone(),
9730                "sk-test-provider-key",
9731                crate::CredentialSource::User,
9732                0,
9733            )
9734            .unwrap();
9735        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9736        config.provider = "openai".to_string();
9737        config.providers.openai = Some(OpenAIConfig {
9738            api_key: "sk-test-provider-key".to_string(),
9739            api_key_encrypted: None,
9740            credential_ref: Some(reference),
9741            base_url: None,
9742            model: None,
9743            fast_model: None,
9744            vision_model: None,
9745            reasoning_effort: None,
9746            responses_only_models: vec![],
9747            request_overrides: None,
9748            extra: Default::default(),
9749            api_key_from_env: false,
9750        });
9751
9752        config
9753            .save_to_dir(temp_home.path.clone())
9754            .expect("save should persist provider reference");
9755
9756        let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9757            .expect("read providers.json");
9758        assert!(
9759            !content.contains("api_key_encrypted"),
9760            "providers.json must not store provider ciphertext"
9761        );
9762        assert!(
9763            !content.contains("\"api_key\""),
9764            "providers.json should not store plaintext provider keys"
9765        );
9766
9767        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9768        let openai = loaded
9769            .providers
9770            .openai
9771            .as_ref()
9772            .expect("openai config should be present");
9773        assert_eq!(openai.api_key, "sk-test-provider-key");
9774
9775        drop(key_guard);
9776    }
9777
9778    #[test]
9779    fn config_save_persists_mcp_servers_in_mainstream_format() {
9780        let _lock = env_lock_acquire();
9781        let temp_home = TempHome::new();
9782
9783        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9784
9785        let mut env = std::collections::HashMap::new();
9786        env.insert("TOKEN".to_string(), "supersecret".to_string());
9787
9788        config.mcp.servers = vec![
9789            bamboo_domain::mcp_config::McpServerConfig {
9790                id: "stdio-secret".to_string(),
9791                name: None,
9792                enabled: true,
9793                transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9794                    bamboo_domain::mcp_config::StdioConfig {
9795                        command: "echo".to_string(),
9796                        args: vec![],
9797                        cwd: None,
9798                        env,
9799                        env_encrypted: std::collections::HashMap::new(),
9800                        env_credential_refs: std::collections::HashMap::new(),
9801                        startup_timeout_ms: 5000,
9802                    },
9803                ),
9804                request_timeout_ms: 5000,
9805                healthcheck_interval_ms: 1000,
9806                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9807                allowed_tools: vec![],
9808                denied_tools: vec![],
9809            },
9810            bamboo_domain::mcp_config::McpServerConfig {
9811                id: "http-secret".to_string(),
9812                name: None,
9813                enabled: true,
9814                transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9815                    bamboo_domain::mcp_config::StreamableHttpConfig {
9816                        url: "http://localhost:8080/http".to_string(),
9817                        headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9818                            name: "Authorization".to_string(),
9819                            value: "Bearer token123".to_string(),
9820                            value_encrypted: None,
9821                            credential_ref: None,
9822                        }],
9823                        connect_timeout_ms: 5000,
9824                    },
9825                ),
9826                request_timeout_ms: 5000,
9827                healthcheck_interval_ms: 1000,
9828                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9829                allowed_tools: vec![],
9830                denied_tools: vec![],
9831            },
9832        ];
9833
9834        config
9835            .save_to_dir(temp_home.path.clone())
9836            .expect("save should persist MCP servers");
9837
9838        let content =
9839            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9840        assert!(
9841            content.contains("\"mcpServers\""),
9842            "config.json should store MCP servers under the mainstream 'mcpServers' key"
9843        );
9844        assert!(
9845            content.contains("supersecret"),
9846            "config.json should persist MCP stdio env in mainstream format"
9847        );
9848        assert!(
9849            content.contains("Bearer token123"),
9850            "config.json should persist MCP Streamable HTTP headers in mainstream format"
9851        );
9852        assert!(
9853            !content.contains("\"env_encrypted\""),
9854            "config.json should not persist legacy env_encrypted fields"
9855        );
9856        assert!(
9857            !content.contains("\"value_encrypted\""),
9858            "config.json should not persist legacy value_encrypted fields"
9859        );
9860
9861        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9862        let stdio = loaded
9863            .mcp
9864            .servers
9865            .iter()
9866            .find(|s| s.id == "stdio-secret")
9867            .expect("stdio server should exist");
9868        match &stdio.transport {
9869            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9870                assert_eq!(
9871                    stdio.env.get("TOKEN").map(|s| s.as_str()),
9872                    Some("supersecret")
9873                );
9874            }
9875            _ => panic!("Expected stdio transport"),
9876        }
9877
9878        let http = loaded
9879            .mcp
9880            .servers
9881            .iter()
9882            .find(|s| s.id == "http-secret")
9883            .expect("http server should exist");
9884        match &http.transport {
9885            bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9886                assert_eq!(http.headers[0].value, "Bearer token123");
9887            }
9888            _ => panic!("Expected Streamable HTTP transport"),
9889        }
9890    }
9891
9892    #[test]
9893    fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9894        let _lock = env_lock_acquire();
9895        let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9896        let temp_home = TempHome::new();
9897        let env_plaintext = "mcp-root-env-plaintext-597";
9898        let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9899        let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9900        std::fs::write(
9901            temp_home.path.join("config.json"),
9902            serde_json::to_vec_pretty(&serde_json::json!({
9903                "features": {"provider_model_ref": true},
9904                "mcpServers": {
9905                    "stdio-root": {
9906                        "command": "unused-disabled-command",
9907                        "env": {"TOKEN": env_plaintext},
9908                        "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9909                        "env_credential_refs": {
9910                            "TOKEN": "mcp.stdio-root.env_TOKEN"
9911                        }
9912                    },
9913                    "http-root": {
9914                        "url": "https://example.test/mcp",
9915                        "transport_kind": "streamable_http",
9916                        "headers": {"Authorization": header_plaintext},
9917                        "header_credential_refs": {
9918                            "Authorization": "mcp.http-root.header_Authorization"
9919                        }
9920                    }
9921                }
9922            }))
9923            .unwrap(),
9924        )
9925        .unwrap();
9926        std::fs::write(
9927            temp_home.path.join("mcp.json"),
9928            serde_json::to_vec_pretty(&serde_json::json!({
9929                "schema_version": 1,
9930                "revision": 7,
9931                "data": {
9932                    "stdio-root": {
9933                        "command": "unused-disabled-command",
9934                        "enabled": false,
9935                        "env_encrypted": {"TOKEN": legacy_ciphertext},
9936                        "request_timeout_ms": 100,
9937                        "healthcheck_interval_ms": 100
9938                    },
9939                    "http-root": {
9940                        "url": "https://example.test/mcp",
9941                        "transport_kind": "streamable_http",
9942                        "enabled": false,
9943                        "headers": {"Authorization": header_plaintext},
9944                        "request_timeout_ms": 100,
9945                        "healthcheck_interval_ms": 100
9946                    }
9947                }
9948            }))
9949            .unwrap(),
9950        )
9951        .unwrap();
9952
9953        crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9954        let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9955            temp_home.path.join("mcp.json"),
9956            1,
9957        )
9958        .load()
9959        .unwrap()
9960        .unwrap();
9961        let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9962        config.mcp = stored.data;
9963        config
9964            .hydrate_mcp_credentials_from_store(&temp_home.path)
9965            .unwrap();
9966
9967        let public = serde_json::to_value(&config.mcp).unwrap();
9968        let rendered_public = public.to_string();
9969        assert!(rendered_public.contains(env_plaintext));
9970        assert!(rendered_public.contains(header_plaintext));
9971        let compatible: bamboo_domain::mcp_config::McpConfig =
9972            serde_json::from_value(public).unwrap();
9973        assert_eq!(compatible.servers.len(), 2);
9974
9975        config.features.provider_model_ref = !config.features.provider_model_ref;
9976        config.save_to_dir(temp_home.path.clone()).unwrap();
9977        config.features.provider_model_ref = !config.features.provider_model_ref;
9978        config.save_to_dir(temp_home.path.clone()).unwrap();
9979
9980        for entry in std::fs::read_dir(&temp_home.path)
9981            .unwrap()
9982            .filter_map(Result::ok)
9983        {
9984            let name = entry.file_name().to_string_lossy().to_string();
9985            if name == "credentials.json" || entry.path().is_dir() {
9986                continue;
9987            }
9988            if name == "config.json"
9989                || name == "mcp.json"
9990                || name.contains(".bak")
9991                || name.starts_with("config-credential-migration")
9992            {
9993                let bytes = std::fs::read(entry.path()).unwrap();
9994                let content = String::from_utf8_lossy(&bytes);
9995                assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9996                assert!(
9997                    !content.contains(header_plaintext),
9998                    "secret leaked to {name}"
9999                );
10000                assert!(
10001                    !content.contains(&legacy_ciphertext),
10002                    "legacy ciphertext leaked to {name}"
10003                );
10004            }
10005        }
10006    }
10007
10008    // ── Env vars lifecycle tests ──────────────────────────────
10009
10010    #[test]
10011    fn env_vars_as_map_includes_only_non_empty_values() {
10012        let mut config = Config::default();
10013        config.env_vars.extend([
10014            EnvVarEntry {
10015                name: "A".to_string(),
10016                value: "val_a".to_string(),
10017                secret: false,
10018                value_encrypted: None,
10019                credential_ref: None,
10020                configured: true,
10021                description: None,
10022            },
10023            EnvVarEntry {
10024                name: "B".to_string(),
10025                value: "".to_string(), // empty → should be excluded
10026                secret: true,
10027                value_encrypted: None,
10028                credential_ref: None,
10029                configured: false,
10030                description: None,
10031            },
10032            EnvVarEntry {
10033                name: "C".to_string(),
10034                value: "  ".to_string(), // whitespace-only → excluded
10035                secret: false,
10036                value_encrypted: None,
10037                credential_ref: None,
10038                configured: true,
10039                description: None,
10040            },
10041            EnvVarEntry {
10042                name: "D".to_string(),
10043                value: "val_d".to_string(),
10044                secret: true,
10045                value_encrypted: Some("enc".to_string()),
10046                credential_ref: None,
10047                configured: true,
10048                description: Some("desc".to_string()),
10049            },
10050        ]);
10051
10052        let map = config.env_vars_as_map();
10053        assert_eq!(map.len(), 2);
10054        assert_eq!(map.get("A"), Some(&"val_a".to_string()));
10055        assert_eq!(map.get("D"), Some(&"val_d".to_string()));
10056        assert!(!map.contains_key("B"));
10057        assert!(!map.contains_key("C"));
10058    }
10059
10060    #[test]
10061    fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
10062        let mut config = Config::default();
10063        config.env_vars.extend([
10064            EnvVarEntry {
10065                name: "PLAIN".to_string(),
10066                value: "visible".to_string(),
10067                secret: false,
10068                value_encrypted: None,
10069                credential_ref: None,
10070                configured: true,
10071                description: None,
10072            },
10073            EnvVarEntry {
10074                name: "SECRET".to_string(),
10075                value: "hidden_value".to_string(),
10076                secret: true,
10077                value_encrypted: Some("enc_data".to_string()),
10078                credential_ref: None,
10079                configured: true,
10080                description: None,
10081            },
10082        ]);
10083
10084        config.sanitize_env_vars_for_disk();
10085
10086        assert_eq!(config.env_vars[0].value, "visible"); // plain kept
10087        assert_eq!(config.env_vars[1].value, ""); // secret cleared
10088    }
10089
10090    #[test]
10091    fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
10092        let mut config = Config::default();
10093        config.env_vars.extend([
10094            EnvVarEntry {
10095                name: "OPEN".to_string(),
10096                value: "val".to_string(),
10097                secret: false,
10098                value_encrypted: None,
10099                credential_ref: None,
10100                configured: true,
10101                description: None,
10102            },
10103            EnvVarEntry {
10104                name: "HIDDEN".to_string(),
10105                value: "real_secret".to_string(),
10106                secret: true,
10107                value_encrypted: Some("enc".to_string()),
10108                credential_ref: None,
10109                configured: true,
10110                description: None,
10111            },
10112        ]);
10113
10114        config.sanitize_env_vars_for_disk();
10115
10116        // Plain value untouched
10117        assert_eq!(config.env_vars[0].value, "val");
10118        // Secret plaintext and legacy ciphertext are removed.
10119        assert_eq!(config.env_vars[1].value, "");
10120        assert!(config.env_vars[1].value_encrypted.is_none());
10121    }
10122
10123    #[test]
10124    fn legacy_env_ciphertext_is_read_but_never_serialized() {
10125        let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
10126        let mut config: Config = serde_json::from_value(serde_json::json!({
10127            "env_vars": [{
10128                "name": "TOKEN",
10129                "secret": true,
10130                "value_encrypted": ciphertext
10131            }]
10132        }))
10133        .unwrap();
10134        config.hydrate_env_vars_from_encrypted();
10135        assert_eq!(config.env_vars[0].value, "my-secret-token");
10136        let serialized = serde_json::to_value(&config).unwrap();
10137        assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
10138    }
10139
10140    #[test]
10141    fn configured_env_ref_missing_from_store_fails_closed() {
10142        let dir = tempfile::tempdir().unwrap();
10143        let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
10144        let mut config = Config::default();
10145        config.env_vars.push(EnvVarEntry {
10146            name: "TOKEN".to_string(),
10147            value: String::new(),
10148            secret: true,
10149            value_encrypted: None,
10150            credential_ref: Some(reference),
10151            configured: true,
10152            description: None,
10153        });
10154        let error = config
10155            .hydrate_env_var_credentials_from_store(dir.path())
10156            .unwrap_err();
10157        assert!(error
10158            .to_string()
10159            .contains("referenced env credential is unavailable"));
10160        assert!(config.env_vars[0].configured);
10161        assert!(config.env_vars[0].value.is_empty());
10162    }
10163
10164    #[test]
10165    fn configured_notification_ref_missing_from_store_fails_closed() {
10166        let dir = tempfile::tempdir().unwrap();
10167        let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
10168        let mut config = Config::default();
10169        config.notifications.ntfy.credential_ref = Some(reference);
10170        config.notifications.ntfy.configured = true;
10171
10172        let error = config
10173            .hydrate_notification_credentials_from_store(dir.path())
10174            .unwrap_err();
10175
10176        assert!(error
10177            .to_string()
10178            .contains("referenced ntfy credential is unavailable"));
10179        assert!(config.notifications.ntfy.configured);
10180        assert!(config.notifications.ntfy.token.is_none());
10181    }
10182
10183    #[test]
10184    fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
10185        let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
10186        let dir = tempfile::tempdir().unwrap();
10187        let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
10188        crate::CredentialStore::open(dir.path())
10189            .replace(
10190                reference.clone(),
10191                "exclusive-notification-secret",
10192                crate::CredentialSource::User,
10193                0,
10194            )
10195            .unwrap();
10196
10197        let mut config = Config::default();
10198        config.notifications.ntfy.credential_ref = Some(reference.clone());
10199        config.notifications.ntfy.configured = true;
10200        config.proxy_auth_credential_ref = Some(reference.clone());
10201        let error = config
10202            .hydrate_notification_credentials_from_store(dir.path())
10203            .unwrap_err();
10204        let rendered = error.to_string();
10205        assert!(rendered
10206            .contains("notification credential reference is shared by another config consumer"));
10207        assert!(!rendered.contains(reference.as_str()));
10208        assert!(config.notifications.ntfy.token.is_none());
10209
10210        config.proxy_auth_credential_ref = None;
10211        config
10212            .hydrate_notification_credentials_from_store(dir.path())
10213            .unwrap();
10214        assert_eq!(
10215            config.notifications.ntfy.token.as_deref(),
10216            Some("exclusive-notification-secret")
10217        );
10218    }
10219
10220    #[test]
10221    fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
10222        let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
10223        let dir = tempfile::tempdir().unwrap();
10224        let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
10225        let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
10226        let bytes = serde_json::to_vec_pretty(&serde_json::json!({
10227            "notifications": {
10228                "ntfy": { "enabled": true, "token_encrypted": ntfy },
10229                "bark": { "enabled": true, "device_key_encrypted": bark }
10230            }
10231        }))
10232        .unwrap();
10233        std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
10234        std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
10235
10236        let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
10237
10238        assert!(loaded.notifications.ntfy.token.is_none());
10239        assert!(loaded.notifications.bark.device_key.is_none());
10240        assert_eq!(
10241            std::fs::read(dir.path().join("config.json")).unwrap(),
10242            bytes
10243        );
10244    }
10245
10246    #[test]
10247    fn publish_and_current_env_vars_round_trip() {
10248        // `publish_env_vars` REPLACES the process-global env-vars cache
10249        // wholesale, so every test that touches that cache must hold the
10250        // crate-wide env lock. This test didn't (issue #486): running
10251        // concurrently with a lock-holding cache test (e.g.
10252        // `from_data_dir_without_publish_does_not_clobber_global_cache`) it
10253        // wiped that test's just-seeded marker out of the cache mid-assert —
10254        // and its own 10x retry loop below was itself a symptom of losing
10255        // the same race in the other direction. With the lock held, one
10256        // publish is deterministic.
10257        let _lock = crate::test_support::env_cache_lock_acquire();
10258        let mut config = Config::default();
10259        config.env_vars.extend([EnvVarEntry {
10260            name: "TEST_PUBLISH".to_string(),
10261            value: "pub_value".to_string(),
10262            secret: false,
10263            value_encrypted: None,
10264            credential_ref: None,
10265            configured: true,
10266            description: None,
10267        }]);
10268
10269        config.publish_env_vars();
10270        assert_eq!(
10271            Config::current_env_vars()
10272                .get("TEST_PUBLISH")
10273                .map(String::as_str),
10274            Some("pub_value")
10275        );
10276    }
10277
10278    #[test]
10279    fn broker_token_round_trips_encrypt_sanitize_hydrate() {
10280        let mut config = Config::default();
10281        config.subagents.broker = Some(BrokerClientConfig {
10282            endpoint: "ws://127.0.0.1:9600".to_string(),
10283            token: "super-secret-token".to_string(),
10284            token_encrypted: None,
10285            credential_ref: None,
10286            configured: false,
10287        });
10288
10289        // Persist path: encrypt then sanitize (what save_to_dir does).
10290        config.refresh_broker_token_encrypted().unwrap();
10291        config.sanitize_broker_token_for_disk();
10292        let broker = config.subagents.broker.as_ref().unwrap();
10293        assert!(broker.token.is_empty(), "plaintext cleared for disk");
10294        assert!(broker.token_encrypted.is_some(), "ciphertext stored");
10295        assert_ne!(
10296            broker.token_encrypted.as_deref(),
10297            Some("super-secret-token")
10298        );
10299
10300        // Load path: hydrate restores plaintext.
10301        config.hydrate_broker_token_from_encrypted();
10302        assert_eq!(
10303            config.subagents.broker.as_ref().unwrap().token,
10304            "super-secret-token"
10305        );
10306    }
10307
10308    #[test]
10309    fn broker_token_empty_refresh_preserves_ciphertext() {
10310        // A redacted round-trip (token empty) must not wipe the stored ciphertext.
10311        let mut config = Config::default();
10312        config.subagents.broker = Some(BrokerClientConfig {
10313            endpoint: "ws://h:9600".to_string(),
10314            token: String::new(),
10315            token_encrypted: Some("existing-cipher".to_string()),
10316            credential_ref: None,
10317            configured: false,
10318        });
10319        config.refresh_broker_token_encrypted().unwrap();
10320        assert_eq!(
10321            config
10322                .subagents
10323                .broker
10324                .as_ref()
10325                .unwrap()
10326                .token_encrypted
10327                .as_deref(),
10328            Some("existing-cipher"),
10329        );
10330    }
10331
10332    #[test]
10333    fn notifications_config_defaults_when_key_missing() {
10334        // Additive/back-compat: an absent `notifications` key must deserialize
10335        // to the built-in defaults (desktop auto, ntfy/bark disabled).
10336        let config: Config = serde_json::from_str("{}").expect("empty object parses");
10337        assert_eq!(config.notifications, NotificationsConfig::default());
10338        assert_eq!(config.notifications.desktop.enabled, None);
10339        assert!(!config.notifications.ntfy.enabled);
10340        assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10341        assert_eq!(config.notifications.ntfy.token, None);
10342        assert!(!config.notifications.bark.enabled);
10343        assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10344        assert_eq!(config.notifications.bark.device_key, None);
10345    }
10346
10347    #[test]
10348    fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10349        let mut config = Config::default();
10350        config.notifications.ntfy = NtfyChannelConfig {
10351            enabled: true,
10352            base_url: "https://ntfy.sh".to_string(),
10353            topic: "bamboo-alerts".to_string(),
10354            token: Some("tk_super_secret".to_string()),
10355            token_encrypted: None,
10356            credential_ref: None,
10357            configured: false,
10358        };
10359
10360        // Legacy compatibility path: retain a readable ciphertext until the
10361        // credential migration moves the secret into the isolated store.
10362        config.refresh_notifications_encrypted().unwrap();
10363        assert!(config.notifications.ntfy.token_encrypted.is_some());
10364        assert_ne!(
10365            config.notifications.ntfy.token_encrypted.as_deref(),
10366            Some("tk_super_secret")
10367        );
10368
10369        // Neither plaintext nor legacy ciphertext is serializable.
10370        let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10371        assert!(
10372            !json.contains("tk_super_secret"),
10373            "plaintext token must never be serialized"
10374        );
10375        assert!(!json.contains("token_encrypted"));
10376
10377        // Legacy load path restores plaintext for migration.
10378        config.notifications.ntfy.token = None;
10379        config.hydrate_notifications_from_encrypted();
10380        assert_eq!(
10381            config.notifications.ntfy.token.as_deref(),
10382            Some("tk_super_secret")
10383        );
10384    }
10385
10386    #[test]
10387    fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10388        let mut config = Config::default();
10389        config.notifications.bark = BarkChannelConfig {
10390            enabled: true,
10391            base_url: "https://api.day.app".to_string(),
10392            device_key: Some("dk_super_secret".to_string()),
10393            device_key_encrypted: None,
10394            credential_ref: None,
10395            configured: false,
10396        };
10397
10398        config.refresh_notifications_encrypted().unwrap();
10399        assert!(config.notifications.bark.device_key_encrypted.is_some());
10400        assert_ne!(
10401            config.notifications.bark.device_key_encrypted.as_deref(),
10402            Some("dk_super_secret")
10403        );
10404
10405        let json = serde_json::to_string(&config.notifications.bark).unwrap();
10406        assert!(
10407            !json.contains("dk_super_secret"),
10408            "plaintext device key must never be serialized"
10409        );
10410        assert!(!json.contains("device_key_encrypted"));
10411
10412        config.notifications.bark.device_key = None;
10413        config.hydrate_notifications_from_encrypted();
10414        assert_eq!(
10415            config.notifications.bark.device_key.as_deref(),
10416            Some("dk_super_secret")
10417        );
10418    }
10419
10420    #[test]
10421    fn notification_secrets_empty_refresh_preserves_ciphertext() {
10422        // The legacy compatibility helper retains already-loaded ciphertext;
10423        // ordinary persistence sanitizes it before writing.
10424        let mut config = Config::default();
10425        config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10426        config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10427
10428        config.refresh_notifications_encrypted().unwrap();
10429
10430        assert_eq!(
10431            config.notifications.ntfy.token_encrypted.as_deref(),
10432            Some("existing-ntfy-cipher")
10433        );
10434        assert_eq!(
10435            config.notifications.bark.device_key_encrypted.as_deref(),
10436            Some("existing-bark-cipher")
10437        );
10438    }
10439
10440    #[test]
10441    fn hydrate_skips_non_secret_entries() {
10442        let mut config = Config::default();
10443        config.env_vars.extend([EnvVarEntry {
10444            name: "PLAIN".to_string(),
10445            value: "original".to_string(),
10446            secret: false,
10447            value_encrypted: Some("should-be-ignored".to_string()),
10448            credential_ref: None,
10449            configured: true,
10450            description: None,
10451        }]);
10452
10453        config.hydrate_env_vars_from_encrypted();
10454        // Non-secret entry should keep its original value
10455        assert_eq!(config.env_vars[0].value, "original");
10456    }
10457
10458    #[test]
10459    fn default_config_has_empty_env_vars() {
10460        // `Config::default()` is a pure in-memory constructor (no disk read, no
10461        // env overrides), so this is independent of the developer's
10462        // `~/.bamboo/config.json` — no temp-dir isolation needed. Directly
10463        // asserts the #38 invariant that default() does not touch the filesystem.
10464        assert!(Config::default().env_vars.is_empty());
10465    }
10466
10467    #[test]
10468    fn serde_round_trip_with_env_vars() {
10469        let mut config = Config::default();
10470        config.env_vars.extend([
10471            EnvVarEntry {
10472                name: "KEY1".to_string(),
10473                value: "val1".to_string(),
10474                secret: false,
10475                value_encrypted: None,
10476                credential_ref: None,
10477                configured: true,
10478                description: Some("First key".to_string()),
10479            },
10480            EnvVarEntry {
10481                name: "KEY2".to_string(),
10482                value: "".to_string(), // on-disk secret has no plaintext
10483                secret: true,
10484                value_encrypted: Some("enc123".to_string()),
10485                credential_ref: None,
10486                configured: true,
10487                description: None,
10488            },
10489        ]);
10490
10491        let json = serde_json::to_string(&config).unwrap();
10492        let restored: Config = serde_json::from_str(&json).unwrap();
10493
10494        assert_eq!(restored.env_vars.len(), 2);
10495        assert_eq!(restored.env_vars[0].name, "KEY1");
10496        assert_eq!(restored.env_vars[0].value, "val1");
10497        assert!(!restored.env_vars[0].secret);
10498        assert_eq!(restored.env_vars[1].name, "KEY2");
10499        assert!(restored.env_vars[1].secret);
10500        assert!(restored.env_vars[1].value_encrypted.is_none());
10501    }
10502
10503    // ---- defaults.* model resolution tests ----
10504
10505    #[test]
10506    // fields set conditionally below
10507    #[allow(clippy::field_reassign_with_default)]
10508    fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10509        let mut config = Config::default();
10510        config.provider = "openai".to_string();
10511        config.providers.openai = Some(OpenAIConfig {
10512            api_key: "test".to_string(),
10513            api_key_encrypted: None,
10514            credential_ref: None,
10515            base_url: None,
10516            model: Some("legacy-gpt-4o".to_string()),
10517            fast_model: None,
10518            vision_model: None,
10519            reasoning_effort: None,
10520            responses_only_models: vec![],
10521            request_overrides: None,
10522            extra: Default::default(),
10523            api_key_from_env: false,
10524        });
10525        config.features.provider_model_ref = true;
10526        config.defaults = Some(DefaultsConfig {
10527            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10528            fast: None,
10529            task_summary: None,
10530            vision: None,
10531            memory_background: None,
10532            planning: None,
10533            search: None,
10534            code_review: None,
10535            sub_agent: None,
10536            subagent_models: Default::default(),
10537        });
10538
10539        assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10540    }
10541
10542    #[test]
10543    // fields set conditionally below
10544    #[allow(clippy::field_reassign_with_default)]
10545    fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10546        let mut config = Config::default();
10547        config.provider = "openai".to_string();
10548        config.providers.openai = Some(OpenAIConfig {
10549            api_key: "test".to_string(),
10550            api_key_encrypted: None,
10551            credential_ref: None,
10552            base_url: None,
10553            model: Some("legacy-gpt-4o".to_string()),
10554            fast_model: None,
10555            vision_model: None,
10556            reasoning_effort: None,
10557            responses_only_models: vec![],
10558            request_overrides: None,
10559            extra: Default::default(),
10560            api_key_from_env: false,
10561        });
10562        config.features.provider_model_ref = false;
10563        config.defaults = Some(DefaultsConfig {
10564            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10565            fast: None,
10566            task_summary: None,
10567            vision: None,
10568            memory_background: None,
10569            planning: None,
10570            search: None,
10571            code_review: None,
10572            sub_agent: None,
10573            subagent_models: Default::default(),
10574        });
10575
10576        assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10577    }
10578
10579    #[test]
10580    // fields set conditionally below
10581    #[allow(clippy::field_reassign_with_default)]
10582    fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10583        let mut config = Config::default();
10584        config.provider = "openai".to_string();
10585        config.providers.openai = Some(OpenAIConfig {
10586            api_key: "test".to_string(),
10587            api_key_encrypted: None,
10588            credential_ref: None,
10589            base_url: None,
10590            model: Some("gpt-4o".to_string()),
10591            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10592            vision_model: None,
10593            reasoning_effort: None,
10594            responses_only_models: vec![],
10595            request_overrides: None,
10596            extra: Default::default(),
10597            api_key_from_env: false,
10598        });
10599        config.features.provider_model_ref = true;
10600        config.defaults = Some(DefaultsConfig {
10601            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10602            fast: Some(bamboo_domain::ProviderModelRef::new(
10603                "anthropic",
10604                "claude-3-5-haiku",
10605            )),
10606            task_summary: None,
10607            vision: None,
10608            memory_background: None,
10609            planning: None,
10610            search: None,
10611            code_review: None,
10612            sub_agent: None,
10613            subagent_models: Default::default(),
10614        });
10615
10616        assert_eq!(
10617            config.get_fast_model(),
10618            Some("claude-3-5-haiku".to_string())
10619        );
10620    }
10621
10622    #[test]
10623    // fields set conditionally below
10624    #[allow(clippy::field_reassign_with_default)]
10625    fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10626        let mut config = Config::default();
10627        config.provider = "openai".to_string();
10628        config.providers.openai = Some(OpenAIConfig {
10629            api_key: "test".to_string(),
10630            api_key_encrypted: None,
10631            credential_ref: None,
10632            base_url: None,
10633            model: Some("gpt-4o".to_string()),
10634            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10635            vision_model: None,
10636            reasoning_effort: None,
10637            responses_only_models: vec![],
10638            request_overrides: None,
10639            extra: Default::default(),
10640            api_key_from_env: false,
10641        });
10642        config.features.provider_model_ref = false;
10643        config.defaults = Some(DefaultsConfig {
10644            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10645            fast: Some(bamboo_domain::ProviderModelRef::new(
10646                "anthropic",
10647                "claude-3-5-haiku",
10648            )),
10649            task_summary: None,
10650            vision: None,
10651            memory_background: None,
10652            planning: None,
10653            search: None,
10654            code_review: None,
10655            sub_agent: None,
10656            subagent_models: Default::default(),
10657        });
10658
10659        assert_eq!(
10660            config.get_fast_model(),
10661            Some("legacy-gpt-4o-mini".to_string())
10662        );
10663    }
10664
10665    #[test]
10666    // fields set conditionally below
10667    #[allow(clippy::field_reassign_with_default)]
10668    fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10669        let mut config = Config::default();
10670        config.provider = "openai".to_string();
10671        config.features.provider_model_ref = true;
10672        config.defaults = Some(DefaultsConfig {
10673            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10674            fast: None,
10675            task_summary: None,
10676            vision: None,
10677            memory_background: None,
10678            planning: None,
10679            search: None,
10680            code_review: None,
10681            sub_agent: None,
10682            subagent_models: Default::default(),
10683        });
10684
10685        assert_eq!(
10686            config.get_fast_model(),
10687            Some("claude-3-7-sonnet".to_string())
10688        );
10689    }
10690
10691    #[test]
10692    // fields set conditionally below
10693    #[allow(clippy::field_reassign_with_default)]
10694    fn get_memory_background_model_prefers_defaults_memory_background() {
10695        let mut config = Config::default();
10696        config.provider = "openai".to_string();
10697        config.providers.openai = Some(OpenAIConfig {
10698            api_key: "test".to_string(),
10699            api_key_encrypted: None,
10700            credential_ref: None,
10701            base_url: None,
10702            model: Some("gpt-4o".to_string()),
10703            fast_model: Some("gpt-4o-mini".to_string()),
10704            vision_model: None,
10705            reasoning_effort: None,
10706            responses_only_models: vec![],
10707            request_overrides: None,
10708            extra: Default::default(),
10709            api_key_from_env: false,
10710        });
10711        config.features.provider_model_ref = true;
10712        config.defaults = Some(DefaultsConfig {
10713            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10714            fast: Some(bamboo_domain::ProviderModelRef::new(
10715                "openai",
10716                "gpt-4o-mini",
10717            )),
10718            task_summary: None,
10719            vision: None,
10720            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10721                "anthropic",
10722                "claude-3-5-haiku",
10723            )),
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_falls_back_to_defaults_fast_when_memory_background_unset() {
10741        let mut config = Config::default();
10742        config.provider = "openai".to_string();
10743        config.features.provider_model_ref = true;
10744        config.defaults = Some(DefaultsConfig {
10745            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10746            fast: Some(bamboo_domain::ProviderModelRef::new(
10747                "anthropic",
10748                "claude-3-5-haiku",
10749            )),
10750            task_summary: None,
10751            vision: None,
10752            memory_background: None,
10753            planning: None,
10754            search: None,
10755            code_review: None,
10756            sub_agent: None,
10757            subagent_models: Default::default(),
10758        });
10759
10760        assert_eq!(
10761            config.get_memory_background_model(),
10762            Some("claude-3-5-haiku".to_string())
10763        );
10764    }
10765
10766    #[test]
10767    // fields set conditionally below
10768    #[allow(clippy::field_reassign_with_default)]
10769    fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10770        let mut config = Config::default();
10771        config.provider = "openai".to_string();
10772        config.providers.openai = Some(OpenAIConfig {
10773            api_key: "test".to_string(),
10774            api_key_encrypted: None,
10775            credential_ref: None,
10776            base_url: None,
10777            model: Some("gpt-4o".to_string()),
10778            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10779            vision_model: None,
10780            reasoning_effort: None,
10781            responses_only_models: vec![],
10782            request_overrides: None,
10783            extra: Default::default(),
10784            api_key_from_env: false,
10785        });
10786        config.features.provider_model_ref = false;
10787        config.defaults = Some(DefaultsConfig {
10788            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10789            fast: Some(bamboo_domain::ProviderModelRef::new(
10790                "anthropic",
10791                "claude-3-5-haiku",
10792            )),
10793            task_summary: None,
10794            vision: None,
10795            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10796                "anthropic",
10797                "claude-3-5-haiku",
10798            )),
10799            planning: None,
10800            search: None,
10801            code_review: None,
10802            sub_agent: None,
10803            subagent_models: Default::default(),
10804        });
10805
10806        assert_eq!(
10807            config.get_memory_background_model(),
10808            Some("legacy-gpt-4o-mini".to_string())
10809        );
10810    }
10811
10812    // -------------------------------------------------------------------
10813    // `is_host_trusted` — plugin source-trust host allowlist (component
10814    // matching, not raw string-prefix matching; see the function's own docs
10815    // for the bypasses this closes).
10816    // -------------------------------------------------------------------
10817
10818    #[test]
10819    fn is_host_trusted_requires_https_scheme() {
10820        let hosts = vec!["github.com/bigduu/".to_string()];
10821        assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10822        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10823    }
10824
10825    #[test]
10826    fn is_host_trusted_is_case_insensitive_on_both_sides() {
10827        // A lowercase URL host against a mixed-case config entry...
10828        let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10829        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10830        // ...and a mixed-case URL host against a lowercase config entry.
10831        let hosts = vec!["github.com/bigduu/".to_string()];
10832        assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10833    }
10834
10835    #[test]
10836    fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10837        let hosts = vec!["trusted.example.com".to_string()];
10838        assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10839        // Both demonstrated bypasses of a raw string-prefix match: gluing a
10840        // longer attacker-controlled label onto the trusted host, with or
10841        // without a separating dot.
10842        assert!(!is_host_trusted(
10843            "https://trusted.example.com.evil.com/x",
10844            &hosts
10845        ));
10846        assert!(!is_host_trusted(
10847            "https://trusted.example.comevil.com/x",
10848            &hosts
10849        ));
10850    }
10851
10852    #[test]
10853    fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10854        // No trailing slash on the config entry's path component.
10855        let hosts = vec!["github.com/bigduu/".to_string()];
10856        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10857        assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10858    }
10859
10860    #[test]
10861    fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10862        let hosts = vec!["example.com".to_string()];
10863        assert!(is_host_trusted("https://example.com/", &hosts));
10864        assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10865        // Still only that exact host — a bare-host entry must not become a
10866        // blanket "any host containing this string" match.
10867        assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10868        assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10869    }
10870
10871    #[test]
10872    fn is_host_trusted_uses_the_real_host_not_userinfo() {
10873        let hosts = vec!["github.com/bigduu/".to_string()];
10874        // `user@host` userinfo does not change the actual host.
10875        assert!(is_host_trusted(
10876            "https://someuser@github.com/bigduu/x",
10877            &hosts
10878        ));
10879        // A decoy host placed in the userinfo position must not be mistaken
10880        // for the real host — the real host here is `evil.com`.
10881        assert!(!is_host_trusted(
10882            "https://github.com@evil.com/bigduu/",
10883            &hosts
10884        ));
10885    }
10886
10887    #[test]
10888    fn is_host_trusted_ignores_an_explicit_port() {
10889        let hosts = vec!["github.com/bigduu/".to_string()];
10890        assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10891    }
10892
10893    #[test]
10894    fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10895        let hosts = vec!["github.com/bigduu/".to_string()];
10896        assert!(!is_host_trusted("not a url at all", &hosts));
10897        assert!(!is_host_trusted("", &hosts));
10898        assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); // no scheme
10899    }
10900
10901    #[test]
10902    fn is_host_trusted_normalizes_dot_segments_before_matching() {
10903        let hosts = vec!["github.com/bigduu/".to_string()];
10904        // `Url::parse` resolves `..` segments before `path()` is ever
10905        // consulted, so this cannot be used to escape the trusted prefix.
10906        assert!(!is_host_trusted(
10907            "https://github.com/bigduu/../evil/x",
10908            &hosts
10909        ));
10910        // A `..` that stays under the trusted prefix once resolved is fine.
10911        assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10912    }
10913
10914    #[test]
10915    fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10916        let mut config = Config::default();
10917        config.plugin_trust.trusted_hosts = vec![
10918            "  GitHub.com/BigDuu/ ".to_string(),
10919            "".to_string(),
10920            "   ".to_string(),
10921            "Example.COM".to_string(),
10922        ];
10923        config.normalize_plugin_trust_settings();
10924        assert_eq!(
10925            config.plugin_trust.trusted_hosts,
10926            vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10927        );
10928    }
10929
10930    // -----------------------------------------------------------------
10931    // `plugin_trust.enforcement` — the persistent, config-level form of the
10932    // `--insecure` escape hatch.
10933    // -----------------------------------------------------------------
10934
10935    #[test]
10936    fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10937        // A fresh `Config::default()` (nothing on disk at all).
10938        let config = Config::default();
10939        assert_eq!(
10940            config.plugin_trust.enforcement,
10941            PluginTrustEnforcement::Strict
10942        );
10943        assert!(!config.plugin_trust.enforcement_is_off());
10944
10945        // A `plugin_trust` object present in JSON but with NO `enforcement`
10946        // key at all (e.g. a config.json written before this field existed)
10947        // must ALSO deserialize to Strict, not fail or silently do something
10948        // else — additive/back-compat, matching `trusted_hosts`/
10949        // `trusted_keys`'s own `#[serde(default = ...)]` behavior.
10950        let json = serde_json::json!({
10951            "trusted_hosts": ["example.com"],
10952            "trusted_keys": [],
10953        });
10954        let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10955        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10956    }
10957
10958    #[test]
10959    fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10960        for raw in ["off", "OFF", "Off", " off "] {
10961            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10962                "enforcement": raw,
10963            }))
10964            .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10965            assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10966            assert!(trust.enforcement_is_off());
10967        }
10968        for raw in ["strict", "STRICT", " Strict "] {
10969            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10970                "enforcement": raw,
10971            }))
10972            .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10973            assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10974        }
10975
10976        let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10977            "enforcement": "nonsense",
10978        }))
10979        .expect_err("an unrecognized string must be rejected, not silently default");
10980        assert!(err.to_string().contains("nonsense"));
10981    }
10982
10983    #[test]
10984    fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10985        // A hand-edited config.json using a plain bool reads naturally: is
10986        // enforcement ON (`true`) or OFF (`false`)?
10987        let trust: PluginTrustConfig =
10988            serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10989        assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10990
10991        let trust: PluginTrustConfig =
10992            serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10993        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10994    }
10995
10996    #[test]
10997    fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10998        // Regardless of which accepted input form produced it, the
10999        // in-memory value always serializes back out as the canonical
11000        // string — this is what the dot-path `config set` setter's
11001        // round-trip check relies on (see `dot_path.rs`'s module docs).
11002        let trust = PluginTrustConfig {
11003            enforcement: PluginTrustEnforcement::Off,
11004            ..PluginTrustConfig::default()
11005        };
11006        let json = serde_json::to_value(&trust).unwrap();
11007        assert_eq!(json["enforcement"], "off");
11008
11009        let trust = PluginTrustConfig {
11010            enforcement: PluginTrustEnforcement::Strict,
11011            ..PluginTrustConfig::default()
11012        };
11013        let json = serde_json::to_value(&trust).unwrap();
11014        assert_eq!(json["enforcement"], "strict");
11015    }
11016
11017    #[test]
11018    fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
11019        // `normalize_plugin_trust_settings` only touches `trusted_hosts` —
11020        // confirm it's a true no-op on `enforcement` either way.
11021        let mut config = Config::default();
11022        config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
11023        config.normalize_plugin_trust_settings();
11024        assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
11025    }
11026
11027    #[test]
11028    fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
11029        // Confirms the dot-path `bamboo config set plugin_trust.enforcement
11030        // off` path actually works end to end through
11031        // `crate::dot_path::apply_dot_path_set` (the generic JSON-patch
11032        // setter), not just direct field assignment.
11033        let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
11034            "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
11035        )));
11036        assert_eq!(
11037            config.plugin_trust.enforcement,
11038            PluginTrustEnforcement::Strict
11039        );
11040
11041        let outcome = crate::dot_path::apply_dot_path_set(
11042            &config,
11043            "plugin_trust.enforcement",
11044            crate::dot_path::parse_cli_value("off"),
11045        )
11046        .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
11047        assert_eq!(
11048            outcome.config.plugin_trust.enforcement,
11049            PluginTrustEnforcement::Off
11050        );
11051
11052        // And back to strict.
11053        let outcome = crate::dot_path::apply_dot_path_set(
11054            &outcome.config,
11055            "plugin_trust.enforcement",
11056            crate::dot_path::parse_cli_value("strict"),
11057        )
11058        .expect("setting it back to strict should also round-trip");
11059        assert_eq!(
11060            outcome.config.plugin_trust.enforcement,
11061            PluginTrustEnforcement::Strict
11062        );
11063    }
11064}
11065
11066#[cfg(test)]
11067mod final_evidence_feature_tests {
11068    use super::FeatureFlags;
11069
11070    #[test]
11071    fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
11072        let existing: FeatureFlags =
11073            serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
11074        assert!(!existing.final_evidence_check);
11075        let enabled: FeatureFlags =
11076            serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
11077        let restored: FeatureFlags =
11078            serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
11079        assert!(restored.final_evidence_check);
11080        assert!(!FeatureFlags::default().final_evidence_check);
11081    }
11082}