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::poison::PoisonRecover;
61use serde::{Deserialize, Serialize};
62use serde_json::Value;
63use std::collections::{BTreeMap, BTreeSet, HashMap};
64use std::path::{Path, PathBuf};
65use std::sync::{OnceLock, RwLock};
66
67use crate::keyword_masking::KeywordMaskingConfig;
68use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
69use bamboo_domain::normalize_tool_ref;
70use bamboo_domain::ReasoningEffort;
71
72/// Minimum accepted watchdog deadline. Zero would turn scheduling jitter into
73/// an immediate stream failure, so it is rejected at the configuration boundary.
74pub const MIN_STREAM_TIMEOUT_SECS: u64 = 1;
75/// Maximum accepted watchdog deadline. This keeps a typo from disabling hung
76/// stream detection for days while still allowing operators to accommodate
77/// exceptionally slow reasoning models.
78pub const MAX_STREAM_TIMEOUT_SECS: u64 = 86_400;
79
80fn default_transport_idle_timeout_secs() -> u64 {
81    120
82}
83
84fn default_first_semantic_timeout_secs() -> u64 {
85    600
86}
87
88fn default_semantic_idle_timeout_secs() -> u64 {
89    600
90}
91
92fn deserialize_stream_timeout_secs<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error>
93where
94    D: serde::Deserializer<'de>,
95{
96    use serde::de::Error as _;
97
98    let value = u64::deserialize(deserializer)?;
99    if (MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
100        Ok(value)
101    } else {
102        Err(D::Error::custom(format!(
103            "stream timeout must be between {MIN_STREAM_TIMEOUT_SECS} and \
104             {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
105        )))
106    }
107}
108
109/// LLM stream watchdog policy shared by the main response path and auxiliary
110/// silent consumers.
111///
112/// Transport and semantic progress are deliberately separate. Valid SSE
113/// lifecycle/keepalive frames refresh `transport_idle_timeout_secs` without
114/// hiding a model that never produces semantic output. The first semantic
115/// deadline starts at request dispatch; after the first token/reasoning/tool
116/// delta, `semantic_idle_timeout_secs` becomes the semantic deadline.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(default)]
119pub struct StreamTimeoutConfig {
120    /// Maximum gap between valid transport frames, including SSE keepalives.
121    #[serde(
122        default = "default_transport_idle_timeout_secs",
123        deserialize_with = "deserialize_stream_timeout_secs"
124    )]
125    pub transport_idle_timeout_secs: u64,
126    /// Maximum time from request dispatch to the first semantic chunk.
127    #[serde(
128        default = "default_first_semantic_timeout_secs",
129        deserialize_with = "deserialize_stream_timeout_secs"
130    )]
131    pub first_semantic_timeout_secs: u64,
132    /// Maximum gap between semantic chunks after semantic output has started.
133    #[serde(
134        default = "default_semantic_idle_timeout_secs",
135        deserialize_with = "deserialize_stream_timeout_secs"
136    )]
137    pub semantic_idle_timeout_secs: u64,
138}
139
140impl Default for StreamTimeoutConfig {
141    fn default() -> Self {
142        Self {
143            transport_idle_timeout_secs: default_transport_idle_timeout_secs(),
144            first_semantic_timeout_secs: default_first_semantic_timeout_secs(),
145            semantic_idle_timeout_secs: default_semantic_idle_timeout_secs(),
146        }
147    }
148}
149
150impl StreamTimeoutConfig {
151    /// Validate values created programmatically (serde performs the same check
152    /// while loading `config.json`).
153    pub fn validate(&self) -> std::result::Result<(), String> {
154        for (name, value) in [
155            (
156                "transport_idle_timeout_secs",
157                self.transport_idle_timeout_secs,
158            ),
159            (
160                "first_semantic_timeout_secs",
161                self.first_semantic_timeout_secs,
162            ),
163            (
164                "semantic_idle_timeout_secs",
165                self.semantic_idle_timeout_secs,
166            ),
167        ] {
168            if !(MIN_STREAM_TIMEOUT_SECS..=MAX_STREAM_TIMEOUT_SECS).contains(&value) {
169                return Err(format!(
170                    "{name} must be between {MIN_STREAM_TIMEOUT_SECS} and \
171                     {MAX_STREAM_TIMEOUT_SECS} seconds, got {value}"
172                ));
173            }
174        }
175        Ok(())
176    }
177}
178
179/// A user-managed environment variable that is injected into Bash tool processes.
180///
181/// Secret entries are stored by reference in the isolated credential store:
182/// `value` is runtime-only for those entries, while `credential_ref` and
183/// `configured` are the only secret metadata persisted in ordinary config.
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct EnvVarEntry {
186    /// Variable name (must match `^[A-Za-z_][A-Za-z0-9_]*$`).
187    pub name: String,
188    /// Plaintext value – populated in memory after hydration.
189    /// For `secret=true` entries this field is empty on disk.
190    #[serde(default, skip_serializing_if = "String::is_empty")]
191    pub value: String,
192    /// Whether this variable contains sensitive data (token, password, etc.).
193    #[serde(default)]
194    pub secret: bool,
195    /// Legacy inline ciphertext, accepted on read only so startup migration can
196    /// extract it. New serializers never emit this field.
197    #[serde(default, skip_serializing)]
198    pub value_encrypted: Option<String>,
199    /// Stable isolated-store reference for secret entries.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub credential_ref: Option<crate::CredentialRef>,
202    /// Durable status metadata; never inferred from a public mask.
203    #[serde(default)]
204    pub configured: bool,
205    /// Optional human-readable description.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub description: Option<String>,
208}
209
210/// Default work area configuration.
211///
212/// Allows Bamboo to operate without an explicit initial workspace while still
213/// providing a stable fallback directory for relative-path tool execution.
214#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
215pub struct DefaultWorkAreaConfig {
216    /// Optional default filesystem path used when a session has no active workspace.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub path: Option<String>,
219}
220
221/// Access control configuration for password-based UI/API gating.
222#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
223pub struct AccessControlConfig {
224    /// Whether password protection is enabled.
225    #[serde(default)]
226    pub password_enabled: bool,
227    /// A malformed legacy verifier or device record was isolated into the
228    /// encrypted recovery store and needs an explicit user repair. Runtime
229    /// authorization treats this as fail-closed even when no usable verifier
230    /// can be hydrated.
231    #[serde(default)]
232    pub repair_required: bool,
233    /// Runtime-only password verifier hash. Legacy documents may still
234    /// deserialize it for migration, but ordinary section serialization never
235    /// writes verifier material.
236    #[serde(default, skip_serializing)]
237    pub password_hash: Option<String>,
238    /// Runtime-only password verifier salt.
239    #[serde(default, skip_serializing)]
240    pub password_salt: Option<String>,
241    /// Stable reference to the encrypted verifier record.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub password_credential_ref: Option<crate::CredentialRef>,
244    /// Durable configured metadata for redacted clients/runtime readiness.
245    #[serde(default)]
246    pub password_configured: bool,
247    /// Last update timestamp for auditing / debugging.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub updated_at: Option<String>,
250    /// v2 (#181): issued per-device tokens. Empty = root-password-only mode
251    /// (back-compat with old instances). Each entry stores only the token hash;
252    /// the plaintext token is returned to the client once at pairing time.
253    #[serde(default, skip_serializing_if = "Vec::is_empty")]
254    pub devices: Vec<DeviceCredential>,
255}
256
257/// A single paired device's credential (v2-P2 per-device token, #181).
258///
259/// The server stores only `token_hash` (never the plaintext token). The hash is
260/// computed with the SAME construction as the access password — `SHA-256(salt ||
261/// token)` — so no new crypto dependency is introduced (`docs/api-v2-transport.md`
262/// §4.2).
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
264pub struct DeviceCredential {
265    /// Server-generated stable id: `bamboo_<12 hex>`.
266    pub device_id: String,
267    /// Human-readable label, e.g. "iPhone 15".
268    pub label: String,
269    /// Runtime-only `SHA-256(hex_decode(token_salt) || token)`, hex-encoded.
270    #[serde(default, skip_serializing)]
271    pub token_hash: String,
272    /// Runtime-only per-device salt (hex-encoded).
273    #[serde(default, skip_serializing)]
274    pub token_salt: String,
275    /// Stable reference to the encrypted device-token verifier record.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub token_credential_ref: Option<crate::CredentialRef>,
278    /// Durable configured metadata for runtime readiness.
279    #[serde(default)]
280    pub token_configured: bool,
281    /// RFC3339 creation timestamp.
282    pub created_at: String,
283    /// RFC3339 last-used timestamp (deferred stamping; see PR).
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub last_used_at: Option<String>,
286    /// Whether this device's token has been revoked. A revoked token is rejected
287    /// at the handshake/middleware immediately.
288    #[serde(default)]
289    pub revoked: bool,
290}
291
292/// Memory and background summarization configuration.
293// No `Eq`: `dedup_gardener_min_score` is an f64 (PartialEq only).
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
295pub struct MemoryConfig {
296    /// Optional dedicated model for memory/session summarization and reflection.
297    /// Falls back to the provider fast model when unset.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub background_model: Option<String>,
300    /// Desired conversation-summary size as a fraction of the raw source tokens
301    /// represented by that summary. The default keeps roughly 20% of source
302    /// content as durable working memory.
303    #[serde(default = "default_summary_target_ratio")]
304    pub summary_target_ratio: f64,
305    /// Maximum fraction of the summarization model context window consumed by a
306    /// fully rendered map/reduce request, including reserved output and safety
307    /// margin.
308    #[serde(default = "default_summary_safe_window_percent")]
309    pub summary_safe_window_percent: u8,
310    /// Whether lightweight automatic Dream-style consolidation should run in the
311    /// background. Default ON (memory redesign L4): each tick no-ops when there is
312    /// no background model configured or no new candidate sessions, so it is free
313    /// until there is real work + a model. Set false to opt out.
314    #[serde(default = "default_true_auto_dream_enabled")]
315    pub auto_dream_enabled: bool,
316    /// Seconds between background auto-Dream ticks (default 30 minutes).
317    /// Each tick still no-ops when there are no new candidate sessions, so raising
318    /// this only lowers how often an active user triggers a real consolidation.
319    #[serde(default = "default_auto_dream_interval_secs")]
320    pub auto_dream_interval_secs: u64,
321    /// Whether project durable-memory index injection is enabled for the main prompt.
322    #[serde(
323        default = "default_true_memory_project_prompt_injection",
324        alias = "memory_project_prompt_injection"
325    )]
326    pub project_prompt_injection: bool,
327    /// Whether automatic relevant durable-memory recall is enabled for the main prompt.
328    #[serde(
329        default = "default_true_memory_relevant_recall",
330        alias = "memory_relevant_recall"
331    )]
332    pub relevant_recall: bool,
333    /// Whether relevant durable-memory recall should rerank lexical shortlist candidates
334    /// using the configured memory/background model.
335    #[serde(default, alias = "memory_relevant_recall_rerank")]
336    pub relevant_recall_rerank: bool,
337    /// Whether Dream prompt injection should prefer project Dream and only use global Dream as fallback.
338    #[serde(
339        default = "default_true_memory_project_first_dream",
340        alias = "memory_project_first_dream"
341    )]
342    pub project_first_dream: bool,
343    /// Whether the ledger agenda (overdue/upcoming prospective records — todos,
344    /// events, reminders) is injected into the main prompt. Free when the
345    /// ledger is empty: the section is simply omitted.
346    #[serde(
347        default = "default_true_memory_ledger_agenda",
348        alias = "memory_ledger_agenda_injection"
349    )]
350    pub ledger_agenda_injection: bool,
351    /// Whether the background ledger gardener runs (expires past events/reminders,
352    /// reconciles record↔schedule drift, distills completed records into durable
353    /// memory). Expiry and reconciliation are deterministic and free; only
354    /// distillation uses the background model, and it no-ops without one.
355    #[serde(default = "default_true_ledger_gardener_enabled")]
356    pub ledger_gardener_enabled: bool,
357    /// Seconds between ledger gardener runs (default 6 hours).
358    #[serde(default = "default_ledger_gardener_interval_secs")]
359    pub ledger_gardener_interval_secs: u64,
360    /// Whether the ledger gardener's distillation pass (completed records →
361    /// durable memories via the background model) is enabled.
362    #[serde(default = "default_true_ledger_distillation_enabled")]
363    pub ledger_distillation_enabled: bool,
364    /// DEPRECATED (memory redesign L3): the "Refine" Dream mode — rewriting the
365    /// notebook from its own prior prose — was retired because a self-referential
366    /// narrative rewrite drifts from durable truth and silently over-merges. The
367    /// notebook is now always a grounded VIEW of the durable memory index (Rebuild)
368    /// or a session bootstrap (Incremental). This field is IGNORED; it is retained
369    /// only so existing config files that set it still deserialize.
370    #[serde(default, alias = "memory_dream_refine_mode")]
371    pub dream_refine_mode: bool,
372    /// Whether the background "gardener" may use the LLM to split/merge "blob" memories.
373    /// Default ON (memory redesign L4). The deterministic blob prefilter is cheap
374    /// and each run is bounded by `gardener_max_splits_per_run`; a run that finds
375    /// nothing, or finds work but has no background model, spends no tokens. Set
376    /// false to opt out.
377    #[serde(
378        default = "default_true_gardener_enabled",
379        alias = "memory_gardener_enabled"
380    )]
381    pub gardener_enabled: bool,
382    /// Seconds between gardener time-triggered runs (default daily). A run may also
383    /// fire early when the library grows — see `gardener_volume_trigger`.
384    #[serde(default = "default_gardener_interval_secs")]
385    pub gardener_interval_secs: u64,
386    /// Run the gardener maintenance pass early (before the next time tick) once this
387    /// many new durable memories have accumulated since the last run, so pileup is
388    /// bounded by growth, not only by the clock (memory redesign L4). 0 disables the
389    /// volume trigger (time-only). Per-run caps still bound the work done.
390    #[serde(default = "default_gardener_volume_trigger")]
391    pub gardener_volume_trigger: usize,
392    /// Hard cap on LLM-backed splits per gardener run (cost ceiling per run).
393    #[serde(default = "default_gardener_max_splits_per_run")]
394    pub gardener_max_splits_per_run: usize,
395    /// Minimum `---` accretions for a memory to be a gardener split candidate.
396    #[serde(default = "default_gardener_min_sections")]
397    pub gardener_min_sections: usize,
398    /// Whether the background dedup gardener may use the LLM to consolidate
399    /// near-duplicate memories. Default ON (memory redesign L4); bounded by
400    /// `dedup_gardener_max_merges_per_run` and no-ops without a model. Set false to
401    /// opt out.
402    #[serde(
403        default = "default_true_dedup_gardener_enabled",
404        alias = "memory_dedup_gardener_enabled"
405    )]
406    pub dedup_gardener_enabled: bool,
407    /// Minimum content-keyword Jaccard (0.0–1.0) for two active memories to be
408    /// flagged as dedup candidates by the deterministic prefilter.
409    #[serde(default = "default_dedup_gardener_min_score")]
410    pub dedup_gardener_min_score: f64,
411    /// Hard cap on LLM-backed consolidations per dedup gardener run (cost ceiling).
412    #[serde(default = "default_dedup_gardener_max_merges_per_run")]
413    pub dedup_gardener_max_merges_per_run: usize,
414    /// Max RECALLABLE (Active/Stale) memories per scope before the capacity gardener
415    /// archives the lowest-value overflow OUT of the recall index (memory redesign
416    /// L5 — archive, never delete; reversible). 0 = unbounded (feature OFF, the
417    /// default): consequential enough to be opt-in, since L4's dedup already curbs
418    /// most growth. `Reference`/`User`/`Feedback` memories are always exempt — so
419    /// the effective floor is the count of exempt Active memories in a scope; set
420    /// this comfortably above that (a capacity below it is a no-op, not a purge).
421    #[serde(default)]
422    pub memory_active_capacity: usize,
423    /// Hard cap on how many memories the capacity gardener archives per run, so a
424    /// large overflow drains gradually instead of in one burst.
425    #[serde(default = "default_capacity_max_archivals_per_run")]
426    pub capacity_max_archivals_per_run: usize,
427    /// Whether the background freshness gardener may conservatively demote Active
428    /// day/week-granularity memories to Stale once they cross their documented
429    /// staleness window (issue #61 phase 2; applied by Jiandu's
430    /// `MemoryStore::expire_stale_granularity`). Default ON,
431    /// matching the other gardener passes: deterministic (no LLM, no cost), and
432    /// non-destructive — it only ever moves Active → Stale, never archives or
433    /// deletes. Set false to opt out.
434    #[serde(default = "default_true_granularity_freshness_gardener_enabled")]
435    pub granularity_freshness_gardener_enabled: bool,
436}
437
438impl Default for MemoryConfig {
439    fn default() -> Self {
440        Self {
441            background_model: None,
442            summary_target_ratio: default_summary_target_ratio(),
443            summary_safe_window_percent: default_summary_safe_window_percent(),
444            auto_dream_enabled: default_true_auto_dream_enabled(),
445            auto_dream_interval_secs: default_auto_dream_interval_secs(),
446            project_prompt_injection: default_true_memory_project_prompt_injection(),
447            relevant_recall: default_true_memory_relevant_recall(),
448            relevant_recall_rerank: false,
449            project_first_dream: default_true_memory_project_first_dream(),
450            ledger_agenda_injection: default_true_memory_ledger_agenda(),
451            ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
452            ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
453            ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
454            dream_refine_mode: false,
455            gardener_enabled: default_true_gardener_enabled(),
456            gardener_interval_secs: default_gardener_interval_secs(),
457            gardener_volume_trigger: default_gardener_volume_trigger(),
458            gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
459            gardener_min_sections: default_gardener_min_sections(),
460            dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
461            dedup_gardener_min_score: default_dedup_gardener_min_score(),
462            dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
463            memory_active_capacity: 0,
464            capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
465            granularity_freshness_gardener_enabled:
466                default_true_granularity_freshness_gardener_enabled(),
467        }
468    }
469}
470
471fn default_summary_target_ratio() -> f64 {
472    0.20
473}
474
475fn default_summary_safe_window_percent() -> u8 {
476    80
477}
478
479fn default_true_granularity_freshness_gardener_enabled() -> bool {
480    true
481}
482
483fn default_capacity_max_archivals_per_run() -> usize {
484    50
485}
486
487fn default_true_auto_dream_enabled() -> bool {
488    true
489}
490
491fn default_true_gardener_enabled() -> bool {
492    true
493}
494
495fn default_true_dedup_gardener_enabled() -> bool {
496    true
497}
498
499fn default_true_memory_ledger_agenda() -> bool {
500    true
501}
502
503fn default_true_ledger_gardener_enabled() -> bool {
504    true
505}
506
507fn default_ledger_gardener_interval_secs() -> u64 {
508    21_600
509}
510
511fn default_true_ledger_distillation_enabled() -> bool {
512    true
513}
514
515/// Fire the gardener maintenance pass early once ~this many new memories accumulate
516/// since the last run. Conservative: large enough to avoid thrashing on a few
517/// writes, small enough to bound pileup well under a full (daily) interval.
518fn default_gardener_volume_trigger() -> usize {
519    25
520}
521
522fn default_gardener_interval_secs() -> u64 {
523    86_400
524}
525
526fn default_auto_dream_interval_secs() -> u64 {
527    60 * 30
528}
529
530fn default_gardener_max_splits_per_run() -> usize {
531    8
532}
533
534fn default_gardener_min_sections() -> usize {
535    5
536}
537
538fn default_dedup_gardener_min_score() -> f64 {
539    0.6
540}
541
542fn default_dedup_gardener_max_merges_per_run() -> usize {
543    8
544}
545
546fn default_true_memory_project_prompt_injection() -> bool {
547    true
548}
549
550fn default_true_memory_relevant_recall() -> bool {
551    true
552}
553
554fn default_true_memory_project_first_dream() -> bool {
555    true
556}
557
558/// Per-run resource guardrails (issue #221): a cost/resource ceiling applied
559/// across an entire `AgentRuntime::execute()` call (i.e. one user turn's worth
560/// of internal rounds — the same "run" granularity `max_rounds` already uses).
561///
562/// Every field is `None` by default (unlimited), matching the rest of this
563/// config's opt-in-only posture. A per-request `ExecuteRequest::run_budget`
564/// override (HTTP `POST /execute` body) may only TIGHTEN this config-level
565/// default, never loosen it — per field, the effective limit is the minimum
566/// of the two (see [`RunBudgetConfig::merged_with_override`] and
567/// `bamboo_engine::runtime::runtime::AgentRuntime::execute`).
568///
569/// Exceeding any configured limit gracefully stops the run (mirrors the
570/// `max_rounds` exhaustion path: one final summary turn, then a terminal stop
571/// with `runtime.completion_reason = "budget_exceeded"` on the session, plus a
572/// structured `AgentEvent::BudgetExceeded`) rather than erroring out — the run
573/// stays resumable.
574#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
575pub struct RunBudgetConfig {
576    /// Maximum total tokens (prompt + completion, actual provider-reported
577    /// usage summed across the run's rounds) before the run is stopped.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub max_total_tokens: Option<u64>,
580    /// Maximum total tool calls (across every round of the run, not just one
581    /// round — see `max_tool_calls_per_round` for the existing per-round cap)
582    /// before the run is stopped.
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub max_tool_calls: Option<u32>,
585    /// Maximum total `SubAgent` create calls (across the whole run) before the
586    /// run is stopped. Distinct from `subagents.max_concurrent`, which caps how
587    /// many child actor processes run AT ONCE, not how many a single run may
588    /// spawn in total over its lifetime.
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub max_subagents: Option<u32>,
591}
592
593/// Tighten-only per-field merge: the effective limit is the MINIMUM of the
594/// config default and the request override, with `None` = unlimited.
595fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
596    match (config_default, request) {
597        (Some(a), Some(b)) => Some(a.min(b)),
598        (Some(a), None) => Some(a),
599        (None, Some(b)) => Some(b),
600        (None, None) => None,
601    }
602}
603
604impl RunBudgetConfig {
605    /// Merge a per-request override with this config-level default,
606    /// **tighten-only** (issue #221, PR #539 review): per field, the
607    /// effective limit is the MINIMUM of the two (`None` = unlimited), so a
608    /// `POST /execute` caller can lower a budget below the operator's
609    /// configured ceiling but can never raise or remove it.
610    ///
611    /// Rationale: `run_budget` is a defensive cost circuit-breaker, and the
612    /// server's other guardrails (`max_rounds`, per-round tool caps, …) are
613    /// not client-overridable at all. A client-loosenable ceiling would be no
614    /// ceiling: any caller of `/execute` could send
615    /// `max_total_tokens: u64::MAX` and erase the operator's cap. Overrides
616    /// looser than the config default are silently clamped to it rather than
617    /// rejected — the caller still gets the strictest applicable budget,
618    /// which is always a safe interpretation of their request.
619    pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
620        let Some(over) = request_override else {
621            return *self;
622        };
623        Self {
624            max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
625            max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
626            max_subagents: min_limit(self.max_subagents, over.max_subagents),
627        }
628    }
629}
630
631/// Authentication/provider posture for `executor = "codex"`.
632///
633/// `None` at the containing config field is interpreted as [`Self::Bamboo`],
634/// keeping old documents backward-compatible while making the safe,
635/// parent-routed mode the runtime default.
636#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
637#[serde(rename_all = "snake_case")]
638pub enum CodexAuthMode {
639    /// Use the invoking user's default `~/.codex` auth and configuration.
640    Inherit,
641    /// Isolated `CODEX_HOME`; `OPENAI_API_KEY` must be explicitly forwarded.
642    ApiKey,
643    /// Isolated `CODEX_HOME` with a custom provider and referenced credential.
644    Custom,
645    /// Isolated `CODEX_HOME`; route through this Bamboo server with a per-run token.
646    #[default]
647    Bamboo,
648}
649
650/// Codex 0.144+ only accepts the Responses wire protocol for custom providers.
651#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
652#[serde(rename_all = "snake_case")]
653pub enum CodexWireApi {
654    #[default]
655    Responses,
656}
657
658/// Codex transport used by `executor = "codex"`.
659#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
660#[serde(rename_all = "snake_case")]
661pub enum CodexMode {
662    /// One `codex exec --json` process per activation.
663    #[default]
664    Exec,
665    /// Long-lived `codex app-server` JSON-RPC session with approval relay.
666    AppServer,
667}
668
669/// OS sandbox selected for non-interactive `codex exec` children.
670#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
671#[serde(rename_all = "kebab-case")]
672pub enum CodexSandbox {
673    ReadOnly,
674    WorkspaceWrite,
675    DangerFullAccess,
676}
677
678/// Codex approval policy. Mode-specific validation rejects interactive policy
679/// in exec mode and non-interactive policy in app-server mode.
680#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
681#[serde(rename_all = "kebab-case")]
682pub enum CodexApprovalPolicy {
683    Never,
684    OnFailure,
685    OnRequest,
686}
687
688/// Sub-agent execution settings.
689///
690/// Sub-agents always run as independent **actor** processes — an isolated OS
691/// process with its own context (crash isolation, true parallelism, per-child
692/// resource limits). The historical in-process runtime was removed, so there is
693/// no longer a runtime toggle (a stray `"runtime"`/`"overrides"` key in an old
694/// config is ignored). The worker binary, its arguments, and the discovery
695/// directory are derived automatically (the current `bamboo` executable +
696/// `subagent-worker`); the expert fields below override them only when you run a
697/// custom worker.
698#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
699pub struct SubagentsConfig {
700    /// Maximum actor activations running at once; further spawns wait their
701    /// turn. Default: 200. Warm-idle process retention has a separate, smaller
702    /// bound.
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    pub max_concurrent: Option<usize>,
705    /// Expert: custom worker binary. Default: the current bamboo executable.
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub worker_bin: Option<String>,
708    /// Expert: arguments for the custom worker binary. Default for the
709    /// built-in worker: `["subagent-worker"]`.
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub worker_args: Option<Vec<String>>,
712    /// Expert: discovery fabric directory. Default: a per-user temp dir.
713    #[serde(default, skip_serializing_if = "Option::is_none")]
714    pub fabric_dir: Option<String>,
715    /// Expert: `"echo"` swaps in a dependency-free smoke executor (no LLM)
716    /// to verify the actor chain end-to-end; `"claude_code"` drives the
717    /// official Claude Code CLI; `"codex"` drives the selected Codex mode.
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub executor: Option<String>,
720    /// `executor = "claude_code"` only: override the `claude` executable.
721    /// `None` runs `claude` resolved from `PATH`.
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub claude_code_binary: Option<String>,
724    /// `executor = "claude_code"` only: `--model` override. `None` omits the
725    /// flag (CLI default).
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub claude_code_model: Option<String>,
728    /// `executor = "claude_code"` only: `--permission-mode` override. `None`
729    /// still passes an EXPLICIT `default` to the CLI (issue #443 — the
730    /// headless stream-json default is `auto`, which self-approves every
731    /// tool and never asks); it does not mean "omit the flag".
732    #[serde(default, skip_serializing_if = "Option::is_none")]
733    pub claude_code_permission_mode: Option<String>,
734    /// `executor = "claude_code"` only: `true` lets the child inherit the
735    /// invoking user's `~/.claude` MCP servers/skills/settings. `false`/unset
736    /// (the default) isolates it (`--strict-mcp-config` +
737    /// `--setting-sources project`).
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub claude_code_inherit_user_config: Option<bool>,
740    /// `executor = "claude_code"` only: extra env var NAMES forwarded
741    /// verbatim from this process's env to the child, on top of the fixed
742    /// HOME/PATH/SHELL/TERM/LANG/LC_*/TMPDIR/USER/LOGNAME allowlist.
743    /// Forwarding `ANTHROPIC_API_KEY` here is an explicit opt-in that flips
744    /// billing from the CLI's own subscription auth to the API key.
745    #[serde(default, skip_serializing_if = "Option::is_none")]
746    pub claude_code_forward_env: Option<Vec<String>>,
747    /// `executor = "codex"` only: override the `codex` executable. `None`
748    /// resolves `codex` from `PATH`.
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub codex_binary: Option<String>,
751    /// `executor = "codex"` only: `--model` override. `None` uses the CLI
752    /// default model.
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub codex_model: Option<String>,
755    /// `executor = "codex"` only: `exec` (default) or long-lived `app_server`.
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub codex_mode: Option<CodexMode>,
758    /// `executor = "codex"` only: authentication/provider mode. Unset defaults
759    /// to `bamboo`, which keeps provider credentials in the parent process.
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub codex_auth_mode: Option<CodexAuthMode>,
762    /// `codex_auth_mode = "custom"` only: absolute HTTP(S) provider base URL.
763    #[serde(default, skip_serializing_if = "Option::is_none")]
764    pub codex_base_url: Option<String>,
765    /// `codex_auth_mode = "custom"` only. Codex 0.144+ supports `responses`.
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub codex_wire_api: Option<CodexWireApi>,
768    /// `codex_auth_mode = "custom"` only: stable reference to an existing
769    /// provider credential. The plaintext key remains in the credential store
770    /// and is injected into the child process environment only.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub codex_provider_key_ref: Option<crate::CredentialRef>,
773    /// Extra env var names copied into Codex after `env_clear()`. API-key mode
774    /// requires an explicit `OPENAI_API_KEY` entry; it is never implicit.
775    #[serde(default, skip_serializing_if = "Option::is_none")]
776    pub codex_forward_env: Option<Vec<String>>,
777    /// Explicit Codex sandbox override. Unset derives the sandbox from the
778    /// child permission profile and the parent session's bypass posture.
779    #[serde(default, skip_serializing_if = "Option::is_none")]
780    pub codex_sandbox: Option<CodexSandbox>,
781    /// Mode-specific approval policy. Exec resolves to a non-interactive safe
782    /// value; app-server requires `on-request` and routes it to the parent.
783    #[serde(default, skip_serializing_if = "Option::is_none")]
784    pub codex_approval_policy: Option<CodexApprovalPolicy>,
785    /// Permit network access from a workspace-write sandbox.
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub codex_network_access: Option<bool>,
788    /// Opt in to disabling the Codex OS sandbox, but only when the parent run
789    /// is itself in bypass mode. False/unset keeps bypass runs sandboxed.
790    #[serde(default, skip_serializing_if = "Option::is_none")]
791    pub codex_allow_danger_bypass: Option<bool>,
792    /// The active message-broker endpoint the `ask_agent` tool / sub-agent bus
793    /// dials. RUNTIME-ONLY (`#[serde(skip)]`): never read from nor written to
794    /// `config.json`. It is populated in memory each boot by `maybe_embed_broker`
795    /// — either from a user-managed external broker in `<data_dir>/broker.json`,
796    /// or from the freshly-embedded in-process broker (whose ephemeral loopback
797    /// port must NEVER be persisted, else a later boot dials a dead port).
798    #[serde(skip)]
799    pub broker: Option<BrokerClientConfig>,
800    /// Remote placements: pin specific sub-agent roles to resident workers
801    /// reached over `wss://` instead of a locally-spawned subprocess
802    /// (remote-actor-plan §3.4 / P1.5, #193). Empty (the default) keeps every
803    /// role on the local path — fully back-compatible: an old config with no
804    /// `remote_placements` key deserializes to an empty vec.
805    #[serde(default, skip_serializing_if = "Vec::is_empty")]
806    pub remote_placements: Vec<RemoteActorPlacement>,
807    /// Schedulable placements: route specific sub-agent roles to a LIVE worker
808    /// resolved from the agent registry at run time, instead of a locally-spawned
809    /// subprocess (remote-actor-plan §3.4 / P2b, #181). Unlike `remote_placements`
810    /// (a fixed endpoint), a schedulable placement names a logical `pool` and a
811    /// `registry_url`; the engine queries the registry for live workers in that
812    /// pool and picks one. Empty (the default) keeps every role on the local path
813    /// — fully back-compatible: an old config with no `schedulable_placements` key
814    /// deserializes to an empty vec.
815    ///
816    /// PRECEDENCE: if a role appears in BOTH `remote_placements` and
817    /// `schedulable_placements`, the fixed remote placement wins (it is resolved
818    /// first in `build_spec`).
819    #[serde(default, skip_serializing_if = "Vec::is_empty")]
820    pub schedulable_placements: Vec<SchedulablePlacement>,
821    /// Per-role allowlist scoping which host-bound MCP tools a sub-agent role
822    /// may see/call through the orchestrator's MCP proxy (issue #54;
823    /// `bamboo_broker::RoleToolAllowlist`). Read and enforced
824    /// ORCHESTRATOR-side when wiring `serve_mcp_proxy` — this is deliberately
825    /// NOT part of the worker-facing `McpProxyConfig` a deployed worker
826    /// receives, because a worker self-declaring its own allowlist would be
827    /// insecure (it could simply claim to be unrestricted). A role absent
828    /// from this list is unrestricted (sees/can call every proxiable tool),
829    /// so adding this policy never silently strips tools from an
830    /// already-deployed role you have not listed here. Empty (the default)
831    /// keeps every role unrestricted — fully backward compatible with
832    /// pre-#54 behavior.
833    ///
834    /// Role AND tool names are matched by exact string equality against the
835    /// worker-asserted `AgentRef.role` / the requested tool name — see
836    /// `RoleToolAllowlist`'s doc comment for the resulting self-asserted-role
837    /// caveat (this policy is adequate against a confused/hallucinating
838    /// worker, not a malicious one that lies about its own role).
839    #[serde(default, skip_serializing_if = "Vec::is_empty")]
840    pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
841}
842
843/// One role's MCP proxy tool allowlist entry (issue #54). See
844/// [`SubagentsConfig::mcp_role_allowlist`].
845#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
846pub struct McpRoleAllowlistEntry {
847    /// Sub-agent role this entry restricts — matches the worker-asserted
848    /// `AgentRef.role` (itself the child session's `subagent_type` /
849    /// `ChildIdentity.role`). There is no fixed registry of valid roles in
850    /// this codebase (roles are free-form profile ids), so a typo here is
851    /// NOT caught against a "known roles" list — only structurally (blank
852    /// names are dropped, duplicates warn) at load time. Double-check this
853    /// against the role string your profile/deploy config actually uses.
854    pub role: String,
855    /// Tool names this role may see in its manifest / call through the
856    /// proxy, matched by exact string equality against the backend's
857    /// registered tool name. An entry with an EMPTY list is an explicit
858    /// lockout (no tools) for that role, distinct from the role being absent
859    /// from this Vec entirely (unrestricted). Validated at load time against
860    /// the orchestrator's live MCP tool set where available — an unknown
861    /// name is still enforced (kept) but logged as a likely typo.
862    #[serde(default)]
863    pub tools: Vec<String>,
864}
865
866/// Routes a single sub-agent role to a registry-scheduled worker (remote-actor-
867/// plan §3.4 / P2b, #181). A child whose `subagent_type` matches `role` is run on
868/// a LIVE worker chosen from the agent registry: the engine builds a
869/// `RegistryFabric` at `registry_url`, lists live workers (the registry already
870/// excludes expired leases), filters to those whose `role` == `pool`, picks one
871/// (round-robin), and connects over `wss://` (Bearer-authenticated). If no live
872/// worker exists the run ERRORS — a schedulable role NEVER falls back to a local
873/// subprocess (that would silently defeat the placement).
874///
875/// The bearer token is NEVER stored here in the clear: `token_env` names the
876/// environment variable that holds it (mirroring `RemoteActorPlacement` /
877/// the A2A `auth_ref` pattern), read once at runner-build time and used for BOTH
878/// the registry query AND the worker connect. A `token_env` that is set-but-unset
879/// at build time fails SAFE — the placement is skipped and the role falls back to
880/// Local rather than querying/connecting unauthenticated.
881#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
882pub struct SchedulablePlacement {
883    /// Sub-agent role this targets (matches the child session's
884    /// `metadata["subagent_type"]`).
885    pub role: String,
886    /// Logical pool name — the registry `role` to query for live workers.
887    pub pool: String,
888    /// VESTIGIAL (Phase 3 retired the HTTP agent registry — pools are now bus
889    /// roles resolved via broker presence). Kept for config back-compat; ignored
890    /// by the resolver. Optional so a placement is just `{role, pool}`.
891    #[serde(default, skip_serializing_if = "String::is_empty")]
892    pub registry_url: String,
893    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
894    /// `auth_ref`). Used for BOTH the registry query and the worker connect.
895    /// `None` ⇒ query/connect without a bearer (trusted link only).
896    #[serde(default, skip_serializing_if = "Option::is_none")]
897    pub token_env: Option<String>,
898    /// PEM file pinning a self-signed worker/registry cert. `None` ⇒ default
899    /// webpki roots.
900    #[serde(default, skip_serializing_if = "Option::is_none")]
901    pub ca_cert_file: Option<String>,
902}
903
904/// Pins a single sub-agent role to a remote resident worker (remote-actor-plan
905/// §3.4 / P1.5). A child whose `subagent_type` matches `role` is connected over
906/// `wss://` to `endpoint` (Bearer-authenticated) instead of being spawned as a
907/// local subprocess. No role match ⇒ that child stays on the local path.
908///
909/// The bearer token is NEVER stored here in the clear: `token_env` names the
910/// environment variable that holds it (mirroring the A2A `auth_ref` pattern),
911/// read once at runner-build time. A `token_env` that is set-but-unset at build
912/// time fails SAFE — the placement is skipped and the role falls back to Local
913/// rather than connecting unauthenticated.
914#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
915pub struct RemoteActorPlacement {
916    /// Sub-agent role this targets (matches the child session's
917    /// `metadata["subagent_type"]`).
918    pub role: String,
919    /// Resident worker endpoint, e.g. `wss://gpu-host:8443` (or `ws://` only on
920    /// a trusted/loopback link).
921    pub endpoint: String,
922    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
923    /// `auth_ref`). `None` ⇒ connect without a bearer (trusted link only).
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub token_env: Option<String>,
926    /// PEM file pinning a self-signed worker cert. `None` ⇒ default webpki roots
927    /// (or plaintext `ws://`).
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub ca_cert_file: Option<String>,
930}
931
932/// How to reach the central sub-agent message broker (`bamboo broker serve`).
933#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
934pub struct BrokerClientConfig {
935    /// Broker WebSocket endpoint, e.g. `ws://broker-host:9600`.
936    pub endpoint: String,
937    /// Bearer token presented in the broker handshake.
938    ///
939    /// Runtime-only plaintext hydrated from the isolated credential store.
940    /// Legacy `broker.json` files may still deserialize this field so the
941    /// manifest migration can extract it, but serializers never emit it.
942    #[serde(default, skip_serializing)]
943    pub token: String,
944    /// Legacy inline ciphertext accepted only for credential migration.
945    #[serde(default, skip_serializing)]
946    pub token_encrypted: Option<String>,
947    /// Stable reference to the external broker bearer token.
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub credential_ref: Option<crate::CredentialRef>,
950    /// Durable configured metadata. Runtime hydration still verifies that the
951    /// referenced credential exists and decrypts successfully.
952    #[serde(default)]
953    pub configured: bool,
954}
955
956impl std::fmt::Debug for BrokerClientConfig {
957    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
958        formatter
959            .debug_struct("BrokerClientConfig")
960            .field("endpoint", &self.endpoint)
961            .field("credential_ref", &self.credential_ref)
962            .field("configured", &self.configured)
963            .finish_non_exhaustive()
964    }
965}
966
967/// Native desktop (OS-notification) delivery channel.
968#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
969pub struct DesktopChannelConfig {
970    /// `None` = auto: on when Bamboo runs as a standalone `bamboo serve`
971    /// process, off when spawned as a sidecar under `--parent-pid` (a native
972    /// shell such as Bodhi owns notification UX in that mode — desktop
973    /// notifications from both the sidecar and the shell would double-fire).
974    /// `Some(_)` is an explicit user override of that default in either
975    /// direction.
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub enabled: Option<bool>,
978}
979
980/// [ntfy.sh](https://ntfy.sh) push notification channel (self-hostable).
981#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
982pub struct NtfyChannelConfig {
983    #[serde(default)]
984    pub enabled: bool,
985    /// ntfy server base URL (public ntfy.sh or a self-hosted instance).
986    #[serde(default = "default_ntfy_base_url")]
987    pub base_url: String,
988    /// Topic to publish to. Priority mapping from notification category is
989    /// left to the delivery sink, not configured here.
990    #[serde(default)]
991    pub topic: String,
992    /// Access token for a protected/self-hosted ntfy instance (public ntfy.sh
993    /// topics need none).
994    ///
995    /// Secret plaintext hydrated in memory from the isolated credential store;
996    /// never serialized in ordinary config.
997    #[serde(default, skip_serializing)]
998    pub token: Option<String>,
999    /// Legacy encrypted ciphertext accepted only for migration.
1000    #[serde(default, skip_serializing)]
1001    pub token_encrypted: Option<String>,
1002    /// Stable isolated credential-store reference.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub credential_ref: Option<crate::CredentialRef>,
1005    /// Whether the referenced credential is expected to exist.
1006    #[serde(default)]
1007    pub configured: bool,
1008}
1009
1010impl Default for NtfyChannelConfig {
1011    fn default() -> Self {
1012        Self {
1013            enabled: false,
1014            base_url: default_ntfy_base_url(),
1015            topic: String::new(),
1016            token: None,
1017            token_encrypted: None,
1018            credential_ref: None,
1019            configured: false,
1020        }
1021    }
1022}
1023
1024fn default_ntfy_base_url() -> String {
1025    "https://ntfy.sh".to_string()
1026}
1027
1028/// [Bark](https://github.com/Finb/Bark) iOS push notification channel.
1029#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1030pub struct BarkChannelConfig {
1031    #[serde(default)]
1032    pub enabled: bool,
1033    /// Bark server base URL (public api.day.app or a self-hosted instance).
1034    #[serde(default = "default_bark_base_url")]
1035    pub base_url: String,
1036    /// Bark device key identifying the target iOS device.
1037    ///
1038    /// Secret plaintext hydrated in memory from the isolated credential store;
1039    /// never serialized in ordinary config.
1040    #[serde(default, skip_serializing)]
1041    pub device_key: Option<String>,
1042    /// Legacy encrypted ciphertext accepted only for migration.
1043    #[serde(default, skip_serializing)]
1044    pub device_key_encrypted: Option<String>,
1045    /// Stable isolated credential-store reference.
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub credential_ref: Option<crate::CredentialRef>,
1048    /// Whether the referenced credential is expected to exist.
1049    #[serde(default)]
1050    pub configured: bool,
1051}
1052
1053impl Default for BarkChannelConfig {
1054    fn default() -> Self {
1055        Self {
1056            enabled: false,
1057            base_url: default_bark_base_url(),
1058            device_key: None,
1059            device_key_encrypted: None,
1060            credential_ref: None,
1061            configured: false,
1062        }
1063    }
1064}
1065
1066fn default_bark_base_url() -> String {
1067    "https://api.day.app".to_string()
1068}
1069
1070/// Notification delivery channels: native desktop plus push-relay services.
1071///
1072/// Additive/back-compat: an absent `notifications` key in `config.json`
1073/// deserializes to the defaults (desktop auto, ntfy/bark disabled).
1074#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1075pub struct NotificationsConfig {
1076    #[serde(default)]
1077    pub desktop: DesktopChannelConfig,
1078    #[serde(default)]
1079    pub ntfy: NtfyChannelConfig,
1080    #[serde(default)]
1081    pub bark: BarkChannelConfig,
1082}
1083
1084/// One IM-platform bridge configured under `[[connect.platforms]]` —
1085/// bamboo-connect (issue #452 / epic #447): drives a bamboo session from an
1086/// external chat platform (Telegram first).
1087#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1088pub struct ConnectPlatformConfig {
1089    /// Stable per-entry identifier (#496). Not part of the original schema —
1090    /// absent on legacy/hand-written entries and on a freshly-echoed new
1091    /// entry from a client. [`Config::save_to_dir`] assigns one (a random
1092    /// UUID) to any entry that lacks it as part of the normal save path
1093    /// (migration-on-write); load never mutates/rewrites the config to
1094    /// backfill it, per #493's never-overwrite-until-confirmed semantics.
1095    ///
1096    /// Used by [`crate::patch::preserve_masked_connect_secrets`] as the
1097    /// FIRST resolution strategy for a masked secret in a settings PATCH,
1098    /// ahead of the positional/`type`-based fallbacks (#490/#492) — an exact
1099    /// id match unambiguously identifies the same logical entry even when
1100    /// two entries share the same `platform_type` and have been reordered,
1101    /// which position+type alone cannot always disambiguate.
1102    #[serde(default, skip_serializing_if = "Option::is_none")]
1103    pub id: Option<String>,
1104    /// Optional first-class Project id assigned to newly-created sessions from
1105    /// this connector. Existing connector sessions keep their persisted
1106    /// membership when configuration changes.
1107    #[serde(default, skip_serializing_if = "Option::is_none")]
1108    pub project_id: Option<bamboo_domain::ProjectId>,
1109    /// Platform adapter selector, e.g. `"telegram"`. Unrecognized values are
1110    /// skipped (with a startup warning) rather than failing config load —
1111    /// forward-compatible with future adapters (Feishu/Slack).
1112    #[serde(rename = "type")]
1113    pub platform_type: String,
1114    /// Platform bot/API token.
1115    ///
1116    /// Secret: encrypted at rest in `token_encrypted`; this plaintext field is
1117    /// never serialized and is hydrated in memory on load (mirrors
1118    /// [`NtfyChannelConfig::token`] / [`BarkChannelConfig::device_key`]).
1119    #[serde(default, skip_serializing)]
1120    pub token: Option<String>,
1121    /// Encrypted ciphertext of `token` (the at-rest representation).
1122    #[serde(default, skip_serializing_if = "Option::is_none")]
1123    pub token_encrypted: Option<String>,
1124    /// Stable reference to the isolated token credential.
1125    #[serde(default, skip_serializing_if = "Option::is_none")]
1126    pub token_credential_ref: Option<crate::CredentialRef>,
1127    /// Durable metadata used by redacted clients without exposing a value.
1128    #[serde(default)]
1129    pub token_configured: bool,
1130    /// Platform app id (Feishu `app_id`). Not a secret — serialized normally.
1131    /// Unused by the Telegram adapter.
1132    #[serde(default, skip_serializing_if = "Option::is_none")]
1133    pub app_id: Option<String>,
1134    /// Platform app secret (Feishu `app_secret`).
1135    ///
1136    /// Secret: encrypted at rest in `app_secret_encrypted`; this plaintext
1137    /// field is never serialized and is hydrated in memory on load (mirrors
1138    /// `token` above).
1139    #[serde(default, skip_serializing)]
1140    pub app_secret: Option<String>,
1141    /// Encrypted ciphertext of `app_secret` (the at-rest representation).
1142    #[serde(default, skip_serializing_if = "Option::is_none")]
1143    pub app_secret_encrypted: Option<String>,
1144    /// Stable reference to the isolated app-secret credential.
1145    #[serde(default, skip_serializing_if = "Option::is_none")]
1146    pub app_secret_credential_ref: Option<crate::CredentialRef>,
1147    /// Durable metadata used by redacted clients without exposing a value.
1148    #[serde(default)]
1149    pub app_secret_configured: bool,
1150    /// Platform domain/base-URL selector (Feishu-only today). Not a secret —
1151    /// serialized normally. `None`/`"feishu"` -> open.feishu.cn, `"lark"` ->
1152    /// open.larksuite.com, an `https://` value -> a private-deployment base
1153    /// URL used verbatim. Validation happens in the server registration arm,
1154    /// not here.
1155    #[serde(default, skip_serializing_if = "Option::is_none")]
1156    pub domain: Option<String>,
1157    /// Platform-scoped user ids allowed to drive a session. Deliberately
1158    /// STRICTER than the general secret-mask precedents: an EMPTY list means
1159    /// deny-all (every inbound message is rejected), not allow-all — a
1160    /// startup warning is logged when a platform has no allowed users.
1161    #[serde(default)]
1162    pub allow_from: Vec<String>,
1163    /// User ids allowed to run privileged/admin commands. Parsed from day one
1164    /// but UNUSED in the MVP (#452) — no admin commands exist yet; reserved
1165    /// for the approvals/admin phase of epic #447.
1166    #[serde(default)]
1167    pub admin_from: Vec<String>,
1168}
1169
1170/// bamboo-connect platform bridges: drive bamboo sessions from IM platforms.
1171/// Additive/back-compat: an absent `connect` key in `config.json`
1172/// deserializes to an empty platform list — fully inert (#452).
1173#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1174pub struct ConnectConfig {
1175    #[serde(default)]
1176    pub platforms: Vec<ConnectPlatformConfig>,
1177}
1178
1179fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1180    config.platforms.is_empty()
1181}
1182
1183/// One publisher key trusted to sign plugin bundles.
1184///
1185/// `algorithm` is a plain string (not an enum) so an unrecognized future value
1186/// in an old/new config just never matches during verification rather than
1187/// failing to deserialize — additive/forward-compatible, matching this
1188/// crate's other config sections. Only `"ed25519"` is understood today.
1189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1190pub struct TrustedKey {
1191    /// Human-readable label (surfaced in logs/CLI output); purely descriptive.
1192    pub label: String,
1193    /// Signature algorithm. Only `"ed25519"` is currently verified.
1194    pub algorithm: String,
1195    /// Hex-encoded public key (32 raw bytes for ed25519).
1196    pub public_key: String,
1197}
1198
1199/// The official plugin-signing keys trusted by default, so an out-of-the-box
1200/// `bamboo plugin install <official release url>` needs no `--allow-unsigned`
1201/// for a bundle those repos' release CI signed. One entry per first-party
1202/// plugin publisher; each repo commits its public half as
1203/// `packaging/plugin/signing-key.pub` (nova) / `plugin/signing-key.pub`
1204/// (magpie) for cross-checking.
1205fn default_trusted_keys() -> Vec<TrustedKey> {
1206    vec![
1207        TrustedKey {
1208            label: "nova (bigduu official)".to_string(),
1209            algorithm: "ed25519".to_string(),
1210            public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
1211                .to_string(),
1212        },
1213        TrustedKey {
1214            label: "magpie (bigduu official)".to_string(),
1215            algorithm: "ed25519".to_string(),
1216            public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
1217                .to_string(),
1218        },
1219    ]
1220}
1221
1222/// Default trusted host+path prefix: the `bigduu` GitHub org/user's own repos
1223/// (e.g. `github.com/bigduu/Nova/releases/...`).
1224fn default_trusted_hosts() -> Vec<String> {
1225    vec!["github.com/bigduu/".to_string()]
1226}
1227
1228/// Plugin URL-install source-trust policy: a host allowlist (is the SOURCE
1229/// authorized?) plus ed25519 publisher keys (is the PUBLISHER authentic?).
1230/// This stacks on top of the checksum layer already enforced in
1231/// `bamboo_plugin::registry::PluginSource::Url` (are the BYTES what the
1232/// caller expected?) — see `bamboo-server`'s `plugin_source.rs` for where all
1233/// three layers are enforced together. A pasted checksum alone cannot
1234/// establish source trust (an attacker who controls the page a checksum was
1235/// copied from can just publish a checksum for their own tampered bundle);
1236/// the host allowlist and signature checks close that gap.
1237///
1238/// Both fields are user-editable (`config.json`, or the config-set HTTP/CLI
1239/// path) so an operator can add their own trusted hosts/keys. Additive/
1240/// back-compat: an absent `plugin_trust` key deserializes to
1241/// [`PluginTrustConfig::default`] (the built-in defaults below), not an empty
1242/// policy — so a fresh install can trust the official nova plugin out of the
1243/// box.
1244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1245pub struct PluginTrustConfig {
1246    /// Host+path prefixes a `url` plugin source may be fetched from without
1247    /// `--allow-untrusted-host`, e.g. `"github.com/bigduu/"` (a bare host
1248    /// with no `/`, e.g. `"example.com"`, matches any path on that exact
1249    /// host). Each entry is split into a host component and a path-prefix
1250    /// component and matched on PARSED URL components — whole-host equality
1251    /// plus a `/`-boundary path-prefix check, never a raw string
1252    /// `starts_with` — see [`is_host_trusted`] for the precise rule and why
1253    /// (it closes a domain-gluing bypass like `example.com` matching
1254    /// `example.com.evil.com`, and a sibling-path bypass like
1255    /// `github.com/bigduu` matching `github.com/bigduu-evil/x`).
1256    #[serde(default = "default_trusted_hosts")]
1257    pub trusted_hosts: Vec<String>,
1258    /// Publisher keys a bundle's `.sig` signature may verify against without
1259    /// `--allow-unsigned`.
1260    #[serde(default = "default_trusted_keys")]
1261    pub trusted_keys: Vec<TrustedKey>,
1262    /// Persistent, config-level escape hatch for the whole three-layer
1263    /// policy above: `Off` makes every `url` plugin install/update behave as
1264    /// if `--insecure` (equivalently, `--allow-untrusted-host
1265    /// --allow-unsigned --allow-unverified`) were passed, WITHOUT needing the
1266    /// per-install flag every time — the "I run a private/dev bamboo and
1267    /// don't want to pass flags on every install" customization. Defaults to
1268    /// [`PluginTrustEnforcement::Strict`] — a fresh config, or one with no
1269    /// `plugin_trust.enforcement` key at all, is secure by default; relaxing
1270    /// it is always an explicit, user-initiated edit
1271    /// (`bamboo config set plugin_trust.enforcement off`), never a silent
1272    /// weakening. See `bamboo-server`'s `plugin_source.rs` for where this is
1273    /// enforced, and `AppState::new` for the loud startup warning emitted
1274    /// whenever a server boots with this set to `Off`.
1275    #[serde(default)]
1276    pub enforcement: PluginTrustEnforcement,
1277}
1278
1279impl Default for PluginTrustConfig {
1280    fn default() -> Self {
1281        Self {
1282            trusted_hosts: default_trusted_hosts(),
1283            trusted_keys: default_trusted_keys(),
1284            enforcement: PluginTrustEnforcement::default(),
1285        }
1286    }
1287}
1288
1289impl PluginTrustConfig {
1290    /// True when `url` is `https` and its host+path match one of
1291    /// `trusted_hosts` on parsed URL components (host compared
1292    /// case-insensitively as a WHOLE string, path matched on a `/` boundary
1293    /// — see the free function [`is_host_trusted`] for the precise rule; an
1294    /// unparseable URL or a non-`https` scheme is never trusted).
1295    pub fn is_host_trusted(&self, url: &str) -> bool {
1296        is_host_trusted(url, &self.trusted_hosts)
1297    }
1298
1299    /// True when `enforcement` is [`PluginTrustEnforcement::Off`] — every
1300    /// `url` plugin install/update should skip the host allowlist, signature,
1301    /// and checksum-requirement layers, exactly as if `--insecure` were
1302    /// passed to that individual install. See the field's doc comment for
1303    /// the full rationale.
1304    pub fn enforcement_is_off(&self) -> bool {
1305        matches!(self.enforcement, PluginTrustEnforcement::Off)
1306    }
1307}
1308
1309/// `plugin_trust.enforcement`: the persistent, config-level form of the
1310/// `--insecure` escape hatch (see [`PluginTrustConfig::enforcement`]).
1311///
1312/// Deserialization accepts either the canonical string form (`"strict"` /
1313/// `"off"`, case-insensitive) or a bool-ish alias (`true` == `Strict`,
1314/// `false` == `Off`) for a hand-edited `config.json` — `true`/`false` read
1315/// naturally as "is enforcement on?". The string form is what
1316/// `bamboo config set plugin_trust.enforcement off` writes (and the only
1317/// form the generic dot-path setter's round-trip check accepts on write,
1318/// since this type always *serializes* back out as a string — see
1319/// `bamboo-config`'s `dot_path` module); the bool alias is a read-side
1320/// convenience for whoever edits `config.json` directly.
1321#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1322#[serde(rename_all = "snake_case")]
1323pub enum PluginTrustEnforcement {
1324    /// Secure by default: the host allowlist, signature, and checksum layers
1325    /// are all enforced (each individually waivable via
1326    /// `--allow-untrusted-host` / `--allow-unsigned` / `--allow-unverified`,
1327    /// or all at once via `--insecure`).
1328    #[default]
1329    Strict,
1330    /// Every `url` plugin install/update skips all three trust layers,
1331    /// without needing any per-install flag. Opt-in only — never the
1332    /// default for a fresh or pre-existing config.
1333    Off,
1334}
1335
1336impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1337    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1338    where
1339        D: serde::Deserializer<'de>,
1340    {
1341        #[derive(Deserialize)]
1342        #[serde(untagged)]
1343        enum Repr {
1344            Bool(bool),
1345            Str(String),
1346        }
1347        match Repr::deserialize(deserializer)? {
1348            Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1349            Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1350            Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1351                "strict" => Ok(PluginTrustEnforcement::Strict),
1352                "off" => Ok(PluginTrustEnforcement::Off),
1353                other => Err(serde::de::Error::custom(format!(
1354                    "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1355                     \"off\""
1356                ))),
1357            },
1358        }
1359    }
1360}
1361
1362/// One `trusted_hosts` entry, split into its host and path-prefix
1363/// components (see [`is_host_trusted`]). `path_prefix` is empty for a
1364/// bare-host entry (e.g. `"example.com"`, meaning "any path on this exact
1365/// host") or starts with `/` (e.g. `"/bigduu/"` from `"github.com/bigduu/"`).
1366struct TrustedHostEntry<'a> {
1367    host: &'a str,
1368    path_prefix: &'a str,
1369}
1370
1371/// Split a raw `trusted_hosts` entry at its first `/` into host + path
1372/// components. Entries are compared against ALREADY-lowercased input by the
1373/// caller ([`is_host_trusted`]), so this does no case normalization itself.
1374fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1375    match entry.find('/') {
1376        Some(index) => TrustedHostEntry {
1377            host: &entry[..index],
1378            path_prefix: &entry[index..],
1379        },
1380        None => TrustedHostEntry {
1381            host: entry,
1382            path_prefix: "",
1383        },
1384    }
1385}
1386
1387/// True when `path` matches `prefix` on a `/` path-component boundary, never
1388/// on a raw byte prefix: exactly equal to `prefix`, or `prefix` ends in `/`
1389/// and `path` starts with it, or the character in `path` immediately
1390/// following `prefix` is `/`. An empty `prefix` (a bare-host trusted_hosts
1391/// entry) matches any path.
1392///
1393/// This is what stops a sibling path from passing as a prefix match — e.g.
1394/// entry `github.com/bigduu` (no trailing slash) must NOT match
1395/// `github.com/bigduu-evil/x`: `"/bigduu-evil/x"` starts with `"/bigduu"` as
1396/// raw bytes, but the character right after the prefix is `-`, not `/`, so
1397/// this correctly refuses it.
1398fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1399    if prefix.is_empty() || path == prefix {
1400        return true;
1401    }
1402    if prefix.ends_with('/') {
1403        return path.starts_with(prefix);
1404    }
1405    path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1406}
1407
1408/// Free function backing [`PluginTrustConfig::is_host_trusted`] — exposed
1409/// separately so callers (and tests) can check a candidate host list without
1410/// constructing a full [`PluginTrustConfig`]/[`Config`].
1411///
1412/// Matches on PARSED URL COMPONENTS, not a raw string prefix: `url` must be
1413/// `https`, its `host_str()` (already correct for userinfo — `user@host` or
1414/// `host@evil.com`-style tricks resolve to the real host, not a decoy — and
1415/// for an explicit port, which `host_str()` excludes) must EQUAL a
1416/// `trusted_hosts` entry's host component (case-insensitively, WHOLE host —
1417/// never a `starts_with`), and its (already dot-segment-normalized by
1418/// `Url::parse`) path must match that entry's path-prefix component on a `/`
1419/// boundary (see [`path_matches_prefix`]). A bare-host entry (no `/` in it)
1420/// has an empty path-prefix, so it matches any path but ONLY on that exact
1421/// host.
1422///
1423/// A prior raw-`starts_with` implementation was defeated by (1) gluing a
1424/// trusted bare host into a longer attacker-controlled one, e.g.
1425/// `trusted.example.com` matching `trusted.example.com.evil.com` /
1426/// `trusted.example.comevil.com`, and (2) a sibling path prefix, e.g.
1427/// `github.com/bigduu` (no trailing slash) matching
1428/// `github.com/bigduu-evil/x`. Component-wise matching closes both: host
1429/// comparison is whole-string equality (no gluing possible), and the path
1430/// check enforces a `/` boundary (no sibling-prefix bypass possible).
1431pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1432    let Ok(parsed) = url::Url::parse(url) else {
1433        return false;
1434    };
1435    if parsed.scheme() != "https" {
1436        return false;
1437    }
1438    let Some(host) = parsed.host_str() else {
1439        return false;
1440    };
1441    let host = host.to_ascii_lowercase();
1442    let path = parsed.path();
1443
1444    trusted_hosts.iter().any(|raw_entry| {
1445        let entry = raw_entry.trim().to_ascii_lowercase();
1446        let parsed_entry = parse_trusted_host_entry(&entry);
1447        host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1448    })
1449}
1450
1451/// Host strategy used when the active model context approaches its input limit.
1452///
1453/// `Summary` remains the compatibility default. `RetrievalWindow` is opt-in and
1454/// archives old exact messages only after the runtime has verified that the
1455/// current-session history capability is callable and the boundary can be
1456/// durably checkpointed.
1457#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1458#[serde(rename_all = "snake_case")]
1459pub enum ContextManagementStrategy {
1460    #[default]
1461    Summary,
1462    RetrievalWindow,
1463}
1464
1465/// Explicit fallback used when retrieval-window cannot satisfy a runtime
1466/// precondition. There is deliberately no implicit summary fallback.
1467#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1468#[serde(rename_all = "snake_case")]
1469pub enum ContextManagementFallbackStrategy {
1470    #[default]
1471    None,
1472    Summary,
1473}
1474
1475fn default_retrieval_window_min_recent_user_turns() -> usize {
1476    3
1477}
1478
1479fn default_retrieval_window_trigger_usage_ratio() -> f64 {
1480    0.80
1481}
1482
1483fn default_retrieval_window_target_usage_ratio() -> f64 {
1484    0.60
1485}
1486
1487fn default_history_tool_required() -> bool {
1488    true
1489}
1490
1491/// Selection and safety policy for the opt-in retrieval-window strategy.
1492#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1493#[serde(default)]
1494pub struct RetrievalWindowContextConfig {
1495    #[serde(default = "default_retrieval_window_min_recent_user_turns")]
1496    pub min_recent_user_turns: usize,
1497    #[serde(default = "default_retrieval_window_trigger_usage_ratio")]
1498    pub trigger_usage_ratio: f64,
1499    #[serde(default = "default_retrieval_window_target_usage_ratio")]
1500    pub target_usage_ratio: f64,
1501    #[serde(default = "default_history_tool_required")]
1502    pub history_tool_required: bool,
1503    #[serde(default)]
1504    pub fallback_strategy: ContextManagementFallbackStrategy,
1505}
1506
1507impl Default for RetrievalWindowContextConfig {
1508    fn default() -> Self {
1509        Self {
1510            min_recent_user_turns: default_retrieval_window_min_recent_user_turns(),
1511            trigger_usage_ratio: default_retrieval_window_trigger_usage_ratio(),
1512            target_usage_ratio: default_retrieval_window_target_usage_ratio(),
1513            history_tool_required: default_history_tool_required(),
1514            fallback_strategy: ContextManagementFallbackStrategy::None,
1515        }
1516    }
1517}
1518
1519/// Backward-compatible context-management configuration snapshot.
1520#[derive(Debug, Clone, Serialize, PartialEq)]
1521pub struct ContextManagementConfig {
1522    pub strategy: ContextManagementStrategy,
1523    pub retrieval_window: RetrievalWindowContextConfig,
1524}
1525
1526impl Default for ContextManagementConfig {
1527    fn default() -> Self {
1528        Self {
1529            strategy: ContextManagementStrategy::Summary,
1530            retrieval_window: RetrievalWindowContextConfig::default(),
1531        }
1532    }
1533}
1534
1535impl ContextManagementConfig {
1536    pub fn is_default(&self) -> bool {
1537        *self == Self::default()
1538    }
1539
1540    pub fn validate(&self) -> std::result::Result<(), String> {
1541        let policy = &self.retrieval_window;
1542        if policy.min_recent_user_turns == 0 {
1543            return Err(
1544                "context_management.retrieval_window.min_recent_user_turns must be greater than zero"
1545                    .to_string(),
1546            );
1547        }
1548        if !policy.target_usage_ratio.is_finite()
1549            || !policy.trigger_usage_ratio.is_finite()
1550            || policy.target_usage_ratio < 0.01
1551            || policy.target_usage_ratio >= policy.trigger_usage_ratio
1552            || policy.trigger_usage_ratio > 1.0
1553        {
1554            return Err(
1555                "context_management retrieval ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1"
1556                    .to_string(),
1557            );
1558        }
1559        if self.strategy == ContextManagementStrategy::RetrievalWindow
1560            && !policy.history_tool_required
1561        {
1562            return Err(
1563                "retrieval_window requires history_tool_required=true in this release".to_string(),
1564            );
1565        }
1566        Ok(())
1567    }
1568
1569    /// Planner policy is intentionally percent-based in the domain primitive.
1570    /// Flooring keeps fractional configuration conservative rather than
1571    /// archiving less history than the configured target permits. The planner's
1572    /// minimum representable positive target is one percent.
1573    pub fn retrieval_target_usage_percent(&self) -> u8 {
1574        ((self.retrieval_window.target_usage_ratio * 100.0).floor() as u8).max(1)
1575    }
1576}
1577
1578impl<'de> Deserialize<'de> for ContextManagementConfig {
1579    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1580    where
1581        D: serde::Deserializer<'de>,
1582    {
1583        #[derive(Deserialize)]
1584        #[serde(default)]
1585        struct Wire {
1586            strategy: ContextManagementStrategy,
1587            retrieval_window: RetrievalWindowContextConfig,
1588        }
1589
1590        impl Default for Wire {
1591            fn default() -> Self {
1592                let defaults = ContextManagementConfig::default();
1593                Self {
1594                    strategy: defaults.strategy,
1595                    retrieval_window: defaults.retrieval_window,
1596                }
1597            }
1598        }
1599
1600        let wire = Wire::deserialize(deserializer)?;
1601        let config = Self {
1602            strategy: wire.strategy,
1603            retrieval_window: wire.retrieval_window,
1604        };
1605        config.validate().map_err(serde::de::Error::custom)?;
1606        Ok(config)
1607    }
1608}
1609
1610/// Main configuration structure for Bamboo agent
1611///
1612/// Contains all settings needed to run the agent, including provider credentials,
1613/// proxy settings, model selection, and server configuration.
1614#[derive(Debug, Clone, Serialize, Deserialize)]
1615#[doc(hidden)]
1616pub struct ConfigValues {
1617    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
1618    #[serde(default)]
1619    pub http_proxy: String,
1620    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
1621    #[serde(default)]
1622    pub https_proxy: String,
1623    /// Proxy authentication credentials
1624    ///
1625    /// Kept in memory only; ordinary config stores `proxy_auth_credential_ref`.
1626    #[serde(skip_serializing)]
1627    pub proxy_auth: Option<ProxyAuth>,
1628    /// Legacy encrypted proxy authentication accepted only for migration.
1629    #[serde(default, skip_serializing_if = "Option::is_none")]
1630    pub proxy_auth_encrypted: Option<String>,
1631    /// Stable credential-store reference for proxy authentication.
1632    #[serde(default, skip_serializing_if = "Option::is_none")]
1633    pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1634    /// Deprecated: Use `providers.copilot.headless_auth` instead
1635    #[serde(default)]
1636    pub headless_auth: bool,
1637
1638    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
1639    #[serde(default = "default_provider")]
1640    pub provider: String,
1641
1642    /// Default model assignments (used when features.provider_model_ref is enabled).
1643    #[serde(default, skip_serializing_if = "Option::is_none")]
1644    pub defaults: Option<DefaultsConfig>,
1645
1646    /// Multi-instance provider configurations keyed by instance id.
1647    ///
1648    /// When `provider_instances` is non-empty, the registry and router prefer
1649    /// instance ids as routing keys. Legacy `providers` / `provider` fields are
1650    /// still supported for backward compatibility; see
1651    /// [`Config::synthesize_legacy_instances`].
1652    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1653    pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1654
1655    /// The default provider instance id used when a request does not specify one.
1656    ///
1657    /// When set, this takes precedence over the legacy `provider` field.
1658    #[serde(default, skip_serializing_if = "Option::is_none")]
1659    pub default_provider_instance: Option<String>,
1660
1661    /// HTTP server configuration
1662    #[serde(default)]
1663    pub server: ServerConfig,
1664
1665    /// Global keyword masking configuration.
1666    ///
1667    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
1668    #[serde(default)]
1669    pub keyword_masking: KeywordMaskingConfig,
1670
1671    /// Anthropic model mapping configuration.
1672    ///
1673    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
1674    #[serde(default)]
1675    pub anthropic_model_mapping: AnthropicModelMapping,
1676
1677    /// Gemini model mapping configuration.
1678    ///
1679    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
1680    #[serde(default)]
1681    pub gemini_model_mapping: GeminiModelMapping,
1682
1683    /// Request preflight hooks.
1684    ///
1685    /// These hooks can inspect and rewrite outgoing requests before they are sent upstream
1686    /// (e.g. image fallback behavior for text-only models).
1687    #[serde(default)]
1688    pub hooks: HooksConfig,
1689
1690    /// User-configured agent lifecycle command or external script handlers.
1691    ///
1692    /// This is intentionally separate from `hooks`, which is already the
1693    /// provider HTTP request-hook namespace. Lifecycle hooks are snapshotted
1694    /// when an agent run starts.
1695    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1696    pub lifecycle_hooks: LifecycleHooksConfig,
1697
1698    /// Global tool toggles.
1699    ///
1700    /// Any tool listed in `disabled` is omitted from the tool schemas sent to the LLM.
1701    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1702    pub tools: ToolsConfig,
1703
1704    /// Global skill toggles.
1705    ///
1706    /// Any skill listed in `disabled` is excluded from skill context construction and
1707    /// cannot be loaded through the skill runtime tools.
1708    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1709    pub skills: SkillsConfig,
1710
1711    /// User-managed environment variables injected into Bash tool processes.
1712    ///
1713    /// Secret entries are encrypted at rest; plaintext values are hydrated in memory.
1714    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1715    pub env_vars: Vec<EnvVarEntry>,
1716
1717    /// Default work area used when a session has no explicit active workspace.
1718    #[serde(default, skip_serializing_if = "Option::is_none")]
1719    pub default_work_area: Option<DefaultWorkAreaConfig>,
1720
1721    /// Access control / password gate configuration.
1722    #[serde(default, skip_serializing_if = "Option::is_none")]
1723    pub access_control: Option<AccessControlConfig>,
1724
1725    /// Feature flags for incremental rollout.
1726    #[serde(default)]
1727    pub features: FeatureFlags,
1728
1729    /// Config-level default per-run token/tool-call/subagent budget (issue
1730    /// #221). `None` fields are unlimited. A per-request `ExecuteRequest`
1731    /// override may only tighten these ceilings, never loosen them; see
1732    /// [`RunBudgetConfig::merged_with_override`].
1733    #[serde(default)]
1734    pub run_budget: RunBudgetConfig,
1735
1736    /// LLM stream liveness and semantic-progress watchdog policy.
1737    #[serde(default)]
1738    pub stream_timeout: StreamTimeoutConfig,
1739
1740    /// Host-owned strategy for bounding provider-visible conversation context.
1741    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
1742    pub context_management: ContextManagementConfig,
1743
1744    /// Remote Cluster Fabric: operator-managed nodes & clusters for deploying
1745    /// `broker-agent` workers locally or over SSH. Additive/back-compat: absent
1746    /// ⇒ empty. SSH secrets are encrypted at rest (see [`crate::cluster_fabric`]).
1747    #[serde(
1748        default,
1749        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1750    )]
1751    pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1752
1753    /// MCP server configuration.
1754    ///
1755    /// Previously persisted in `mcp.json` (now unified into `config.json`).
1756    // On disk we use the mainstream `mcpServers` key (matching Claude Desktop / MCP ecosystem
1757    // conventions). We still accept the legacy `mcp` key for backward compatibility.
1758    #[serde(default, rename = "mcpServers", alias = "mcp")]
1759    pub mcp: bamboo_domain::mcp_config::McpConfig,
1760
1761    /// Notification delivery channels (desktop + push-relay services).
1762    /// Secrets (ntfy token, Bark device key) live in the isolated credential
1763    /// store; only stable references/configured metadata persist here.
1764    #[serde(default)]
1765    pub notifications: NotificationsConfig,
1766
1767    /// bamboo-connect IM-platform bridges (Telegram first, #452 / epic #447).
1768    /// Secrets (each platform's `token`) are encrypted at rest — see
1769    /// [`Config::hydrate_connect_platform_tokens_from_encrypted`] /
1770    /// [`Config::refresh_connect_platform_tokens_encrypted`].
1771    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1772    pub connect: ConnectConfig,
1773
1774    /// Plugin URL-install source-trust policy (host allowlist + ed25519
1775    /// publisher keys). See [`PluginTrustConfig`]'s docs for the three-layer
1776    /// model this stacks with the checksum layer.
1777    #[serde(default)]
1778    pub plugin_trust: PluginTrustConfig,
1779
1780    /// Extension fields stored at the root of `config.json`.
1781    ///
1782    /// This keeps the config forward-compatible and allows unrelated subsystems
1783    /// (e.g. setup UI state) to persist their own keys without getting dropped by
1784    /// typed (de)serialization.
1785    #[serde(default, flatten)]
1786    pub extra: BTreeMap<String, Value>,
1787}
1788
1789impl Default for ConfigValues {
1790    fn default() -> Self {
1791        Self {
1792            http_proxy: String::new(),
1793            https_proxy: String::new(),
1794            proxy_auth: None,
1795            proxy_auth_encrypted: None,
1796            proxy_auth_credential_ref: None,
1797            headless_auth: false,
1798            run_budget: RunBudgetConfig::default(),
1799            stream_timeout: StreamTimeoutConfig::default(),
1800            context_management: ContextManagementConfig::default(),
1801            cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
1802            provider: default_provider(),
1803            provider_instances: HashMap::new(),
1804            default_provider_instance: None,
1805            server: ServerConfig::default(),
1806            keyword_masking: KeywordMaskingConfig::default(),
1807            anthropic_model_mapping: AnthropicModelMapping::default(),
1808            gemini_model_mapping: GeminiModelMapping::default(),
1809            hooks: HooksConfig::default(),
1810            lifecycle_hooks: LifecycleHooksConfig::default(),
1811            tools: ToolsConfig::default(),
1812            skills: SkillsConfig::default(),
1813            env_vars: Vec::new(),
1814            default_work_area: None,
1815            access_control: None,
1816            features: FeatureFlags::default(),
1817            defaults: None,
1818            mcp: bamboo_domain::mcp_config::McpConfig::default(),
1819            notifications: NotificationsConfig::default(),
1820            connect: ConnectConfig::default(),
1821            plugin_trust: PluginTrustConfig::default(),
1822            extra: BTreeMap::new(),
1823        }
1824    }
1825}
1826
1827/// Network-facing root configuration. Flattening preserves the historical
1828/// top-level JSON keys while making the persisted root structurally modular.
1829#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1830struct NetworkConfigSection {
1831    #[serde(default)]
1832    http_proxy: String,
1833    #[serde(default)]
1834    https_proxy: String,
1835    #[serde(skip_serializing)]
1836    proxy_auth: Option<ProxyAuth>,
1837    #[serde(default, skip_serializing_if = "Option::is_none")]
1838    proxy_auth_encrypted: Option<String>,
1839    #[serde(default, skip_serializing_if = "Option::is_none")]
1840    proxy_auth_credential_ref: Option<crate::CredentialRef>,
1841    #[serde(default)]
1842    headless_auth: bool,
1843    #[serde(default)]
1844    server: ServerConfig,
1845}
1846
1847#[derive(Debug, Clone, Serialize, Deserialize)]
1848struct ProviderRoutingConfigSection {
1849    #[serde(default = "default_provider")]
1850    provider: String,
1851    #[serde(default, skip_serializing_if = "Option::is_none")]
1852    defaults: Option<DefaultsConfig>,
1853    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1854    provider_instances: HashMap<String, ProviderInstanceConfig>,
1855    #[serde(default, skip_serializing_if = "Option::is_none")]
1856    default_provider_instance: Option<String>,
1857}
1858
1859impl Default for ProviderRoutingConfigSection {
1860    fn default() -> Self {
1861        Self {
1862            provider: default_provider(),
1863            defaults: None,
1864            provider_instances: HashMap::new(),
1865            default_provider_instance: None,
1866        }
1867    }
1868}
1869
1870#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1871struct ModelBehaviorConfigSection {
1872    #[serde(default)]
1873    keyword_masking: KeywordMaskingConfig,
1874    #[serde(default)]
1875    anthropic_model_mapping: AnthropicModelMapping,
1876    #[serde(default)]
1877    gemini_model_mapping: GeminiModelMapping,
1878    #[serde(default)]
1879    hooks: HooksConfig,
1880    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1881    lifecycle_hooks: LifecycleHooksConfig,
1882}
1883
1884#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1885struct ToolingConfigSection {
1886    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1887    tools: ToolsConfig,
1888    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1889    skills: SkillsConfig,
1890    #[serde(default, rename = "mcpServers", alias = "mcp")]
1891    mcp: bamboo_domain::mcp_config::McpConfig,
1892}
1893
1894#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1895struct WorkspaceConfigSection {
1896    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1897    env_vars: Vec<EnvVarEntry>,
1898    #[serde(default, skip_serializing_if = "Option::is_none")]
1899    default_work_area: Option<DefaultWorkAreaConfig>,
1900    #[serde(default, skip_serializing_if = "Option::is_none")]
1901    access_control: Option<AccessControlConfig>,
1902}
1903
1904#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1905struct ExecutionConfigSection {
1906    #[serde(default)]
1907    features: FeatureFlags,
1908    #[serde(default)]
1909    run_budget: RunBudgetConfig,
1910    #[serde(default)]
1911    stream_timeout: StreamTimeoutConfig,
1912    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
1913    context_management: ContextManagementConfig,
1914    #[serde(
1915        default,
1916        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1917    )]
1918    cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1919}
1920
1921#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1922struct IntegrationConfigSection {
1923    #[serde(default)]
1924    notifications: NotificationsConfig,
1925    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1926    connect: ConnectConfig,
1927}
1928
1929#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1930struct PluginSecurityConfigSection {
1931    #[serde(default)]
1932    plugin_trust: PluginTrustConfig,
1933}
1934
1935// Count declarations from the same field list that defines each structural
1936// budgeted type, so the constants cannot drift from the actual structs.
1937macro_rules! count_fields {
1938    ($($field:ident),* $(,)?) => {
1939        <[()]>::len(&[$(count_fields!(@one $field)),*])
1940    };
1941    (@one $field:ident) => { () };
1942}
1943
1944macro_rules! define_counted_struct {
1945    (
1946        $(#[$struct_meta:meta])*
1947        $visibility:vis struct $name:ident {
1948            $(
1949                $(#[$field_meta:meta])*
1950                $field_visibility:vis $field:ident: $field_type:ty
1951            ),* $(,)?
1952        }
1953        count $count_visibility:vis $count_name:ident;
1954    ) => {
1955        $(#[$struct_meta])*
1956        $visibility struct $name {
1957            $(
1958                $(#[$field_meta])*
1959                $field_visibility $field: $field_type,
1960            )*
1961        }
1962
1963        $count_visibility const $count_name: usize = count_fields!($($field),*);
1964    };
1965}
1966
1967define_counted_struct! {
1968    /// The root-only persistence DTO written to `config.json`.
1969    ///
1970    /// Every section is flattened so existing documents keep their historical
1971    /// top-level shape. The structural field count is nevertheless nine rather
1972    /// than the Phase-#39 baseline of 31.
1973    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
1974    struct ConfigRoot {
1975        #[serde(flatten)]
1976        network: NetworkConfigSection,
1977        #[serde(flatten)]
1978        provider_routing: ProviderRoutingConfigSection,
1979        #[serde(flatten)]
1980        model_behavior: ModelBehaviorConfigSection,
1981        #[serde(flatten)]
1982        tooling: ToolingConfigSection,
1983        #[serde(flatten)]
1984        workspace: WorkspaceConfigSection,
1985        #[serde(flatten)]
1986        execution: ExecutionConfigSection,
1987        #[serde(flatten)]
1988        integrations: IntegrationConfigSection,
1989        #[serde(flatten)]
1990        plugin_security: PluginSecurityConfigSection,
1991        #[serde(default, flatten)]
1992        extra: BTreeMap<String, Value>,
1993    }
1994    count pub PERSISTED_ROOT_FIELD_COUNT;
1995}
1996
1997impl From<ConfigValues> for ConfigRoot {
1998    fn from(values: ConfigValues) -> Self {
1999        // Deliberately exhaustive: adding a runtime compatibility field must
2000        // update its persisted section mapping or this conversion stops compiling.
2001        let ConfigValues {
2002            http_proxy,
2003            https_proxy,
2004            proxy_auth,
2005            proxy_auth_encrypted,
2006            proxy_auth_credential_ref,
2007            headless_auth,
2008            provider,
2009            defaults,
2010            provider_instances,
2011            default_provider_instance,
2012            server,
2013            keyword_masking,
2014            anthropic_model_mapping,
2015            gemini_model_mapping,
2016            hooks,
2017            lifecycle_hooks,
2018            tools,
2019            skills,
2020            env_vars,
2021            default_work_area,
2022            access_control,
2023            features,
2024            run_budget,
2025            stream_timeout,
2026            context_management,
2027            cluster_fabric,
2028            mcp,
2029            notifications,
2030            connect,
2031            plugin_trust,
2032            extra,
2033        } = values;
2034
2035        Self {
2036            network: NetworkConfigSection {
2037                http_proxy,
2038                https_proxy,
2039                proxy_auth,
2040                proxy_auth_encrypted,
2041                proxy_auth_credential_ref,
2042                headless_auth,
2043                server,
2044            },
2045            provider_routing: ProviderRoutingConfigSection {
2046                provider,
2047                defaults,
2048                provider_instances,
2049                default_provider_instance,
2050            },
2051            model_behavior: ModelBehaviorConfigSection {
2052                keyword_masking,
2053                anthropic_model_mapping,
2054                gemini_model_mapping,
2055                hooks,
2056                lifecycle_hooks,
2057            },
2058            tooling: ToolingConfigSection { tools, skills, mcp },
2059            workspace: WorkspaceConfigSection {
2060                env_vars,
2061                default_work_area,
2062                access_control,
2063            },
2064            execution: ExecutionConfigSection {
2065                features,
2066                run_budget,
2067                stream_timeout,
2068                context_management,
2069                cluster_fabric,
2070            },
2071            integrations: IntegrationConfigSection {
2072                notifications,
2073                connect,
2074            },
2075            plugin_security: PluginSecurityConfigSection { plugin_trust },
2076            extra,
2077        }
2078    }
2079}
2080
2081impl From<ConfigRoot> for ConfigValues {
2082    fn from(root: ConfigRoot) -> Self {
2083        // Keep every root and section destructure exhaustive so a newly added
2084        // persisted field cannot be silently omitted from the runtime view.
2085        let ConfigRoot {
2086            network,
2087            provider_routing,
2088            model_behavior,
2089            tooling,
2090            workspace,
2091            execution,
2092            integrations,
2093            plugin_security,
2094            extra,
2095        } = root;
2096        let NetworkConfigSection {
2097            http_proxy,
2098            https_proxy,
2099            proxy_auth,
2100            proxy_auth_encrypted,
2101            proxy_auth_credential_ref,
2102            headless_auth,
2103            server,
2104        } = network;
2105        let ProviderRoutingConfigSection {
2106            provider,
2107            defaults,
2108            provider_instances,
2109            default_provider_instance,
2110        } = provider_routing;
2111        let ModelBehaviorConfigSection {
2112            keyword_masking,
2113            anthropic_model_mapping,
2114            gemini_model_mapping,
2115            hooks,
2116            lifecycle_hooks,
2117        } = model_behavior;
2118        let ToolingConfigSection { tools, skills, mcp } = tooling;
2119        let WorkspaceConfigSection {
2120            env_vars,
2121            default_work_area,
2122            access_control,
2123        } = workspace;
2124        let ExecutionConfigSection {
2125            features,
2126            run_budget,
2127            stream_timeout,
2128            context_management,
2129            cluster_fabric,
2130        } = execution;
2131        let IntegrationConfigSection {
2132            notifications,
2133            connect,
2134        } = integrations;
2135        let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2136
2137        Self {
2138            http_proxy,
2139            https_proxy,
2140            proxy_auth,
2141            proxy_auth_encrypted,
2142            proxy_auth_credential_ref,
2143            headless_auth,
2144            provider,
2145            defaults,
2146            provider_instances,
2147            default_provider_instance,
2148            server,
2149            keyword_masking,
2150            anthropic_model_mapping,
2151            gemini_model_mapping,
2152            hooks,
2153            lifecycle_hooks,
2154            tools,
2155            skills,
2156            env_vars,
2157            default_work_area,
2158            access_control,
2159            features,
2160            run_budget,
2161            stream_timeout,
2162            context_management,
2163            cluster_fabric,
2164            mcp,
2165            notifications,
2166            connect,
2167            plugin_trust,
2168            extra,
2169        }
2170    }
2171}
2172
2173/// Serialize the root-only durable document.
2174///
2175/// Public `Config` serialization intentionally retains the historical
2176/// compatibility shape, including the legacy `provider` selector. Durable
2177/// instance-native writes are narrower: the explicit instance default is the
2178/// routing authority, so legacy routing fields must not be written back.
2179fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2180    let instance_native = values
2181        .default_provider_instance
2182        .as_ref()
2183        .is_some_and(|id| values.provider_instances.contains_key(id));
2184    let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2185    if instance_native {
2186        if let Some(object) = value.as_object_mut() {
2187            object.remove("provider");
2188            object.remove("providers");
2189        }
2190    }
2191    Ok(value)
2192}
2193
2194define_counted_struct! {
2195    /// Runtime configuration facade. Phase-1 sidecar domains are typed modules;
2196    /// the remaining values keep field-access compatibility through `Deref`.
2197    #[derive(Debug, Clone)]
2198    pub struct Config {
2199        values: ConfigValues,
2200        pub(crate) memory: crate::MemoryConfigModule,
2201        pub(crate) subagents: crate::SubagentsConfigModule,
2202        pub(crate) providers: crate::ProviderConfigsModule,
2203        recovery_status: Option<ConfigRecoveryStatus>,
2204    }
2205    count pub CONFIG_FIELD_COUNT;
2206}
2207
2208/// Auditable structural budgets from Issue #590.
2209pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2210const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2211const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2212
2213impl std::ops::Deref for Config {
2214    type Target = ConfigValues;
2215
2216    fn deref(&self) -> &Self::Target {
2217        &self.values
2218    }
2219}
2220
2221impl std::ops::DerefMut for Config {
2222    fn deref_mut(&mut self) -> &mut Self::Target {
2223        &mut self.values
2224    }
2225}
2226
2227impl Serialize for Config {
2228    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2229    where
2230        S: serde::Serializer,
2231    {
2232        self.to_compatibility_value()
2233            .map_err(serde::ser::Error::custom)?
2234            .serialize(serializer)
2235    }
2236}
2237
2238impl<'de> Deserialize<'de> for Config {
2239    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2240    where
2241        D: serde::Deserializer<'de>,
2242    {
2243        use serde::de::Error;
2244
2245        let mut value = Value::deserialize(deserializer)?;
2246        let object = value
2247            .as_object_mut()
2248            .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2249        let memory = object
2250            .remove("memory")
2251            .map(serde_json::from_value)
2252            .transpose()
2253            .map_err(D::Error::custom)?
2254            .unwrap_or_default();
2255        let subagents = object
2256            .remove("subagents")
2257            .map(serde_json::from_value)
2258            .transpose()
2259            .map_err(D::Error::custom)?
2260            .unwrap_or_default();
2261        let providers = object
2262            .remove("providers")
2263            .map(serde_json::from_value)
2264            .transpose()
2265            .map_err(D::Error::custom)?
2266            .unwrap_or_default();
2267        let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2268
2269        Ok(Self::from_parts(root.into(), memory, subagents, providers))
2270    }
2271}
2272
2273/// Where a [`ConfigRecoveryStatus`]'s recovered values came from. #153.
2274#[derive(Debug, Clone, PartialEq, Serialize)]
2275#[serde(tag = "kind", rename_all = "snake_case")]
2276pub enum ConfigRecoverySource {
2277    /// Field-by-field salvage from the corrupt file itself
2278    /// ([`Config::salvage_partial`]); `fields` lists the top-level keys that
2279    /// were recovered from the corrupt document (any other field fell back to
2280    /// the backup/default baseline instead).
2281    Salvaged { fields: Vec<String> },
2282    /// Recovered wholesale from a `config.json.bak[.N]` generation
2283    /// (`generation` 0 == `.bak`, 1 == `.bak.1`, …).
2284    Backup { generation: usize },
2285    /// No usable salvage or backup; fell back to built-in defaults.
2286    Defaults,
2287}
2288
2289/// Describes a pending config-corruption recovery (#153, following on from
2290/// #37/#135's quarantine + salvage/backup chain): `config.json` failed to
2291/// parse at load time, the corrupt original was quarantined (copied aside,
2292/// not deleted) to `quarantine_path`, and the owning [`Config`] holds the
2293/// recovered in-memory state instead.
2294///
2295/// [`Config::save_to_dir`] refuses to overwrite `config.json` while
2296/// `confirmed` is `false`, so a user who would rather hand-fix the original
2297/// isn't surprised by an automatic overwrite on the next save. Call
2298/// [`Config::confirm_recovery`] (or [`Config::confirm_recovery_and_save_to_dir`])
2299/// to allow the next save through.
2300#[derive(Debug, Clone, PartialEq, Serialize)]
2301pub struct ConfigRecoveryStatus {
2302    /// Where the recovered values came from.
2303    pub source: ConfigRecoverySource,
2304    /// Absolute path of the preserved copy of the corrupt original
2305    /// (`config.json.corrupted.<nanos>`), or `None` if even the quarantine
2306    /// copy failed (the corrupt original still remains in place at
2307    /// `config.json` itself either way — quarantining copies, it doesn't
2308    /// move — so the guard below still applies).
2309    pub quarantine_path: Option<PathBuf>,
2310    /// Set `true` once the user has explicitly confirmed the recovery; only
2311    /// then may `save_to_dir` persist over the original `config.json`.
2312    pub confirmed: bool,
2313}
2314
2315/// Container for provider-specific configurations
2316///
2317/// Each field is optional, allowing users to configure only the providers they need.
2318#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2319pub struct ProviderConfigs {
2320    /// OpenAI provider configuration
2321    #[serde(skip_serializing_if = "Option::is_none")]
2322    pub openai: Option<OpenAIConfig>,
2323    /// Anthropic provider configuration
2324    #[serde(skip_serializing_if = "Option::is_none")]
2325    pub anthropic: Option<AnthropicConfig>,
2326    /// Google Gemini provider configuration
2327    #[serde(skip_serializing_if = "Option::is_none")]
2328    pub gemini: Option<GeminiConfig>,
2329    /// GitHub Copilot provider configuration
2330    #[serde(skip_serializing_if = "Option::is_none")]
2331    pub copilot: Option<CopilotConfig>,
2332    /// Bodhi proxy provider configuration
2333    #[serde(skip_serializing_if = "Option::is_none")]
2334    pub bodhi: Option<BodhiConfig>,
2335
2336    /// Preserve unknown provider keys (forward compatibility).
2337    #[serde(default, flatten)]
2338    pub extra: BTreeMap<String, Value>,
2339}
2340
2341impl ProviderConfigs {
2342    /// Remove only the known legacy selector and built-in aliases while
2343    /// preserving unknown forward-compatible provider entries in `extra`.
2344    pub fn clear_legacy_builtin_aliases(&mut self) {
2345        self.openai = None;
2346        self.anthropic = None;
2347        self.gemini = None;
2348        self.copilot = None;
2349        self.bodhi = None;
2350        self.extra.remove("provider");
2351    }
2352}
2353
2354/// Feature flags for incremental rollout of new subsystems.
2355#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2356pub struct FeatureFlags {
2357    /// Enable the ProviderModelRef system (multi-provider + unified model selection).
2358    #[serde(default)]
2359    pub provider_model_ref: bool,
2360    /// Enable MiniLoop-based complexity evaluation and dynamic per-round model switching.
2361    #[serde(default)]
2362    pub dynamic_model_routing: bool,
2363}
2364
2365/// Default model assignments for specific capabilities.
2366///
2367/// Used when `features.provider_model_ref` is enabled.
2368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2369pub struct DefaultsConfig {
2370    pub chat: bamboo_domain::ProviderModelRef,
2371    #[serde(default, skip_serializing_if = "Option::is_none")]
2372    pub fast: Option<bamboo_domain::ProviderModelRef>,
2373    #[serde(default, skip_serializing_if = "Option::is_none")]
2374    pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2375    #[serde(default, skip_serializing_if = "Option::is_none")]
2376    pub vision: Option<bamboo_domain::ProviderModelRef>,
2377    #[serde(default, skip_serializing_if = "Option::is_none")]
2378    pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2379    /// Model for planning/coordination tasks (task decomposition, architecture).
2380    /// Falls back to `chat` when unset.
2381    #[serde(default, skip_serializing_if = "Option::is_none")]
2382    pub planning: Option<bamboo_domain::ProviderModelRef>,
2383    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
2384    /// Falls back to `fast` when unset.
2385    #[serde(default, skip_serializing_if = "Option::is_none")]
2386    pub search: Option<bamboo_domain::ProviderModelRef>,
2387    /// Model for code review tasks.
2388    /// Falls back to `chat` when unset.
2389    #[serde(default, skip_serializing_if = "Option::is_none")]
2390    pub code_review: Option<bamboo_domain::ProviderModelRef>,
2391    /// Default model for child SubAgent runs.
2392    /// Falls back to `fast`, then `chat` when unset.
2393    #[serde(
2394        default,
2395        skip_serializing_if = "Option::is_none",
2396        alias = "sub_session"
2397    )]
2398    pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2399    /// Per-subagent-type model overrides.
2400    /// Key = subagent_type (e.g. "researcher", "coder"), Value = ProviderModelRef.
2401    /// Falls back to `chat` when no match is found for a given type.
2402    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2403    pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2404}
2405
2406/// Request hook configuration.
2407#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2408pub struct HooksConfig {
2409    /// Image fallback behavior for OpenAI-compatible requests (chat/responses).
2410    #[serde(default)]
2411    pub image_fallback: ImageFallbackHookConfig,
2412}
2413
2414/// Default deadline for one lifecycle command hook.
2415pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2416/// Smallest accepted lifecycle hook deadline at the config API boundary.
2417pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2418/// Largest accepted lifecycle hook deadline (10 minutes). Lifecycle hooks run
2419/// inline with agent progress, so they share the same upper bound as Bamboo's
2420/// interactive shell tool instead of allowing an accidental hours-long stall.
2421pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2422
2423/// Stable user-facing event keys accepted by `lifecycle_hooks`.
2424pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 8] = [
2425    "SessionStart",
2426    "UserPromptSubmit",
2427    "PreToolUse",
2428    "PostToolUse",
2429    "Stop",
2430    "SessionEnd",
2431    "PreCompact",
2432    "Notification",
2433];
2434
2435fn default_lifecycle_hook_timeout_ms() -> u64 {
2436    DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2437}
2438
2439fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2440    *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2441}
2442
2443fn lifecycle_hook_enabled_default() -> bool {
2444    true
2445}
2446
2447fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2448    *value
2449}
2450
2451/// Config-driven agent lifecycle hooks.
2452///
2453/// Event names deliberately preserve the user-facing PascalCase protocol.
2454/// Server-owned events use the same stable schema as engine-owned events.
2455#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2456pub struct LifecycleHooksConfig {
2457    #[serde(default)]
2458    pub enabled: bool,
2459    #[serde(
2460        default,
2461        rename = "SessionStart",
2462        skip_serializing_if = "Vec::is_empty"
2463    )]
2464    pub session_start: Vec<LifecycleHookGroup>,
2465    #[serde(
2466        default,
2467        rename = "UserPromptSubmit",
2468        skip_serializing_if = "Vec::is_empty"
2469    )]
2470    pub user_prompt_submit: Vec<LifecycleHookGroup>,
2471    #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2472    pub pre_tool_use: Vec<LifecycleHookGroup>,
2473    #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2474    pub post_tool_use: Vec<LifecycleHookGroup>,
2475    #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2476    pub stop: Vec<LifecycleHookGroup>,
2477    #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2478    pub session_end: Vec<LifecycleHookGroup>,
2479    #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2480    pub pre_compact: Vec<LifecycleHookGroup>,
2481    #[serde(
2482        default,
2483        rename = "Notification",
2484        skip_serializing_if = "Vec::is_empty"
2485    )]
2486    pub notification: Vec<LifecycleHookGroup>,
2487}
2488
2489impl LifecycleHooksConfig {
2490    pub fn is_empty(&self) -> bool {
2491        !self.enabled
2492            && self.session_start.is_empty()
2493            && self.user_prompt_submit.is_empty()
2494            && self.pre_tool_use.is_empty()
2495            && self.post_tool_use.is_empty()
2496            && self.stop.is_empty()
2497            && self.session_end.is_empty()
2498            && self.pre_compact.is_empty()
2499            && self.notification.is_empty()
2500    }
2501}
2502
2503/// A matcher and its ordered handler list for one lifecycle event.
2504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2505pub struct LifecycleHookGroup {
2506    /// A disabled group remains persisted and editable but is not registered
2507    /// for execution. Missing values default to true for old config files.
2508    #[serde(
2509        default = "lifecycle_hook_enabled_default",
2510        skip_serializing_if = "lifecycle_hook_enabled_is_default"
2511    )]
2512    pub enabled: bool,
2513    #[serde(default, skip_serializing_if = "Option::is_none")]
2514    pub matcher: Option<String>,
2515    #[serde(default)]
2516    pub hooks: Vec<LifecycleHookHandler>,
2517}
2518
2519impl Default for LifecycleHookGroup {
2520    fn default() -> Self {
2521        Self {
2522            enabled: true,
2523            matcher: None,
2524            hooks: Vec::new(),
2525        }
2526    }
2527}
2528
2529/// One configured lifecycle hook handler.
2530///
2531/// The internally tagged representation preserves the existing command JSON
2532/// while allowing handler-specific validation for external scripts.
2533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2534#[serde(tag = "type", rename_all = "lowercase")]
2535pub enum LifecycleHookHandler {
2536    Command {
2537        command: String,
2538        #[serde(
2539            default = "default_lifecycle_hook_timeout_ms",
2540            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2541        )]
2542        timeout_ms: u64,
2543    },
2544    Script {
2545        path: String,
2546        #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2547        runner: LifecycleScriptRunner,
2548        #[serde(
2549            default = "default_lifecycle_hook_timeout_ms",
2550            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2551        )]
2552        timeout_ms: u64,
2553    },
2554}
2555
2556/// Runtime used to execute a lifecycle script.
2557///
2558/// `auto` infers the language from the file extension and tries the system
2559/// runtimes in a deterministic order. Explicit runners are useful when both
2560/// Node.js and Bun are installed or when a deployment standardizes one binary.
2561#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2562#[serde(rename_all = "lowercase")]
2563pub enum LifecycleScriptRunner {
2564    #[default]
2565    Auto,
2566    Node,
2567    Bun,
2568    Python,
2569    Bash,
2570    PowerShell,
2571    Cmd,
2572}
2573
2574impl LifecycleScriptRunner {
2575    pub fn is_auto(&self) -> bool {
2576        matches!(self, Self::Auto)
2577    }
2578
2579    pub fn as_str(self) -> &'static str {
2580        match self {
2581            Self::Auto => "auto",
2582            Self::Node => "node",
2583            Self::Bun => "bun",
2584            Self::Python => "python",
2585            Self::Bash => "bash",
2586            Self::PowerShell => "powershell",
2587            Self::Cmd => "cmd",
2588        }
2589    }
2590
2591    /// Whether this runner can execute the supplied supported script path.
2592    pub fn supports_path(self, path: &str) -> bool {
2593        let extension = lifecycle_script_extension(path);
2594        match self {
2595            Self::Auto => extension.is_some(),
2596            Self::Node | Self::Bun => {
2597                matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2598            }
2599            Self::Python => matches!(extension.as_deref(), Some("py")),
2600            Self::Bash => matches!(extension.as_deref(), Some("sh")),
2601            Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2602            Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2603        }
2604    }
2605}
2606
2607/// Return the normalized extension when the path names a supported lifecycle
2608/// script.
2609pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2610    let extension = std::path::Path::new(path)
2611        .extension()?
2612        .to_str()?
2613        .to_ascii_lowercase();
2614    matches!(
2615        extension.as_str(),
2616        "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2617    )
2618    .then_some(extension)
2619}
2620
2621impl LifecycleHookHandler {
2622    pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2623        Self::Command {
2624            command: command.into(),
2625            timeout_ms,
2626        }
2627    }
2628
2629    pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2630        Self::Script {
2631            path: path.into(),
2632            runner,
2633            timeout_ms,
2634        }
2635    }
2636
2637    pub fn timeout_ms(&self) -> u64 {
2638        match self {
2639            Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2640        }
2641    }
2642}
2643
2644/// Request override configuration for provider-specific HTTP behavior.
2645///
2646/// Overrides are merged in this order (later wins):
2647/// 1. `common`
2648/// 2. `endpoints[endpoint]`
2649/// 3. matching `rules` (sorted by specificity)
2650#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2651pub struct RequestOverridesConfig {
2652    /// Overrides applied to all endpoints.
2653    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2654    pub common: RequestScopeOverride,
2655    /// Endpoint-specific overrides (`chat_completions`, `responses`, `messages`, etc.).
2656    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2657    pub endpoints: BTreeMap<String, RequestScopeOverride>,
2658    /// Model-conditional overrides.
2659    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2660    pub rules: Vec<ModelRequestRule>,
2661}
2662
2663/// A conditional override rule matching a model pattern.
2664#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2665pub struct ModelRequestRule {
2666    /// Model pattern (exact: `gpt-4o`, prefix wildcard: `gpt-5*`).
2667    pub model_pattern: String,
2668    /// Optional endpoint constraint.
2669    #[serde(default, skip_serializing_if = "Option::is_none")]
2670    pub endpoint: Option<String>,
2671    /// Overrides applied when this rule matches.
2672    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2673    pub scope: RequestScopeOverride,
2674}
2675
2676/// Request overrides applied in a specific scope.
2677#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2678pub struct RequestScopeOverride {
2679    /// Extra or overridden HTTP headers.
2680    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2681    pub headers: BTreeMap<String, TemplateExpr>,
2682    /// JSON body patch operations.
2683    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2684    pub body_patch: Vec<BodyPatch>,
2685}
2686
2687impl RequestScopeOverride {
2688    pub fn is_empty(&self) -> bool {
2689        self.headers.is_empty() && self.body_patch.is_empty()
2690    }
2691}
2692
2693/// Body patch operation.
2694#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2695pub struct BodyPatch {
2696    /// Target path (`foo.bar.0` or `/foo/bar/0`).
2697    pub path: String,
2698    /// Operation type.
2699    #[serde(default)]
2700    pub op: BodyPatchOp,
2701    /// Value for `set` operation.
2702    #[serde(default, skip_serializing_if = "Option::is_none")]
2703    pub value: Option<PatchValue>,
2704}
2705
2706/// Supported body patch operations.
2707#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2708#[serde(rename_all = "snake_case")]
2709pub enum BodyPatchOp {
2710    #[default]
2711    Set,
2712    Remove,
2713}
2714
2715/// Body patch value: either a template expression or a raw JSON value.
2716#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2717#[serde(untagged)]
2718pub enum PatchValue {
2719    Template(TemplateExpr),
2720    Json(Value),
2721}
2722
2723/// String template expression used by headers/body patch values.
2724#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2725#[serde(untagged)]
2726pub enum TemplateExpr {
2727    /// Shorthand literal value.
2728    Literal(String),
2729    /// Structured template expression.
2730    Structured(TemplateExprSpec),
2731}
2732
2733/// Structured template expression.
2734#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2735#[serde(tag = "type", rename_all = "snake_case")]
2736pub enum TemplateExprSpec {
2737    /// Literal string value.
2738    Literal { value: String },
2739    /// Reference a value from Bamboo env vars.
2740    EnvRef {
2741        name: String,
2742        #[serde(default, skip_serializing_if = "Option::is_none")]
2743        fallback: Option<String>,
2744    },
2745    /// Generate a runtime value.
2746    Generated { generator: GeneratedValue },
2747    /// Format string with placeholders (`{env:NAME}`, `{uuid}`, `{unix_ms}`).
2748    Format { template: String },
2749}
2750
2751/// Supported generated value kinds.
2752#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2753#[serde(rename_all = "snake_case")]
2754pub enum GeneratedValue {
2755    Uuid,
2756    UnixMs,
2757}
2758
2759/// Global tool toggle configuration.
2760#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2761pub struct ToolsConfig {
2762    /// Tool names that are disabled globally.
2763    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2764    pub disabled: Vec<String>,
2765
2766    /// Preserve tool configuration owned by newer Bamboo versions or plugins.
2767    #[serde(default, flatten)]
2768    pub extra: BTreeMap<String, Value>,
2769}
2770
2771impl ToolsConfig {
2772    fn is_empty(&self) -> bool {
2773        self.disabled.is_empty() && self.extra.is_empty()
2774    }
2775}
2776
2777/// Global skill toggle configuration.
2778#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2779pub struct SkillsConfig {
2780    /// Skill IDs that are disabled globally.
2781    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2782    pub disabled: Vec<String>,
2783
2784    /// Preserve skill configuration owned by newer Bamboo versions or plugins.
2785    #[serde(default, flatten)]
2786    pub extra: BTreeMap<String, Value>,
2787}
2788
2789impl SkillsConfig {
2790    fn is_empty(&self) -> bool {
2791        self.disabled.is_empty() && self.extra.is_empty()
2792    }
2793}
2794
2795/// When a request contains image parts but the effective provider path is text-only,
2796/// we can either:
2797/// - error fast (preferred for strict setups), or
2798/// - degrade gracefully by replacing images with a placeholder text.
2799#[derive(Debug, Clone, Serialize, Deserialize)]
2800pub struct ImageFallbackHookConfig {
2801    #[serde(default = "default_true_hooks")]
2802    pub enabled: bool,
2803
2804    /// "placeholder" (default) or "error"
2805    #[serde(default = "default_image_fallback_mode")]
2806    pub mode: String,
2807}
2808
2809impl Default for ImageFallbackHookConfig {
2810    fn default() -> Self {
2811        Self {
2812            enabled: default_true_hooks(),
2813            mode: default_image_fallback_mode(),
2814        }
2815    }
2816}
2817
2818fn default_image_fallback_mode() -> String {
2819    "placeholder".to_string()
2820}
2821
2822fn default_true_hooks() -> bool {
2823    // Default to disabled so image inputs are preserved unless the user explicitly
2824    // opts into fallback rewriting (placeholder/error/ocr).
2825    false
2826}
2827
2828/// OpenAI provider configuration
2829///
2830/// # Example
2831///
2832/// ```json
2833/// "openai": {
2834///   "api_key": "sk-...",
2835///   "base_url": "https://api.openai.com/v1",
2836///   "model": "gpt-4"
2837/// }
2838/// ```
2839pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
2840
2841#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2842pub struct OpenAIConfig {
2843    /// OpenAI API key (plaintext, in-memory only).
2844    ///
2845    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
2846    #[serde(default, skip_serializing)]
2847    pub api_key: String,
2848    /// Encrypted OpenAI API key (nonce:ciphertext).
2849    #[serde(default, skip_serializing_if = "Option::is_none")]
2850    pub api_key_encrypted: Option<String>,
2851    /// Stable reference to the isolated credential store.
2852    #[serde(default, skip_serializing_if = "Option::is_none")]
2853    pub credential_ref: Option<crate::CredentialRef>,
2854    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
2855    /// Such keys are runtime-only and MUST NOT be re-encrypted into
2856    /// `api_key_encrypted` on save (that would bake the secret into
2857    /// config.json). Not (de)serialized. (#253)
2858    #[serde(skip)]
2859    pub api_key_from_env: bool,
2860    /// Custom API base URL (for Azure or self-hosted deployments)
2861    #[serde(skip_serializing_if = "Option::is_none")]
2862    pub base_url: Option<String>,
2863    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
2864    #[serde(skip_serializing_if = "Option::is_none")]
2865    pub model: Option<String>,
2866    /// Fast/cheap model for lightweight tasks (title generation and summarization).
2867    /// Falls back to `model` when not set.
2868    #[serde(default, skip_serializing_if = "Option::is_none")]
2869    pub fast_model: Option<String>,
2870    /// Vision-capable model for image understanding tasks.
2871    /// Falls back to `model` when not set.
2872    #[serde(default, skip_serializing_if = "Option::is_none")]
2873    pub vision_model: Option<String>,
2874    /// Default reasoning effort for OpenAI requests.
2875    #[serde(skip_serializing_if = "Option::is_none")]
2876    pub reasoning_effort: Option<ReasoningEffort>,
2877
2878    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
2879    ///
2880    /// Example:
2881    /// ```json
2882    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
2883    /// ```
2884    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2885    pub responses_only_models: Vec<String>,
2886    /// Optional request overrides (headers/body patches/model rules).
2887    #[serde(default, skip_serializing_if = "Option::is_none")]
2888    pub request_overrides: Option<RequestOverridesConfig>,
2889
2890    /// Preserve unknown keys under `providers.openai`.
2891    #[serde(default, flatten)]
2892    pub extra: BTreeMap<String, Value>,
2893}
2894
2895impl OpenAIConfig {
2896    /// Whether Bamboo may lower its provider-neutral cache plan into GPT-5.6+
2897    /// `prompt_cache_options` and `prompt_cache_breakpoint` fields.
2898    ///
2899    /// This defaults to enabled. OpenAI-compatible upstreams that have not yet
2900    /// implemented the explicit-cache request fields can opt out without
2901    /// disabling `prompt_cache_key` or the upstream's implicit prompt cache.
2902    pub fn explicit_prompt_cache_enabled(&self) -> bool {
2903        self.extra
2904            .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
2905            .and_then(Value::as_bool)
2906            .unwrap_or(true)
2907    }
2908}
2909
2910/// Anthropic provider configuration
2911///
2912/// # Example
2913///
2914/// ```json
2915/// "anthropic": {
2916///   "api_key": "sk-ant-...",
2917///   "model": "claude-3-5-sonnet-20241022",
2918///   "max_tokens": 4096
2919/// }
2920/// ```
2921#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2922pub struct AnthropicConfig {
2923    /// Anthropic API key (plaintext, in-memory only).
2924    ///
2925    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
2926    #[serde(default, skip_serializing)]
2927    pub api_key: String,
2928    /// Encrypted Anthropic API key (nonce:ciphertext).
2929    #[serde(default, skip_serializing_if = "Option::is_none")]
2930    pub api_key_encrypted: Option<String>,
2931    /// Stable reference to the isolated credential store.
2932    #[serde(default, skip_serializing_if = "Option::is_none")]
2933    pub credential_ref: Option<crate::CredentialRef>,
2934    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
2935    /// Such keys are runtime-only and MUST NOT be re-encrypted into
2936    /// `api_key_encrypted` on save (that would bake the secret into
2937    /// config.json). Not (de)serialized. (#253)
2938    #[serde(skip)]
2939    pub api_key_from_env: bool,
2940    /// Custom API base URL
2941    #[serde(skip_serializing_if = "Option::is_none")]
2942    pub base_url: Option<String>,
2943    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
2944    #[serde(skip_serializing_if = "Option::is_none")]
2945    pub model: Option<String>,
2946    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
2947    /// Falls back to `model` when not set.
2948    #[serde(default, skip_serializing_if = "Option::is_none")]
2949    pub fast_model: Option<String>,
2950    /// Vision-capable model for image understanding tasks.
2951    /// Falls back to `model` when not set.
2952    #[serde(default, skip_serializing_if = "Option::is_none")]
2953    pub vision_model: Option<String>,
2954    /// Maximum tokens in model response
2955    #[serde(skip_serializing_if = "Option::is_none")]
2956    pub max_tokens: Option<u32>,
2957    /// Default reasoning effort for Anthropic requests.
2958    #[serde(skip_serializing_if = "Option::is_none")]
2959    pub reasoning_effort: Option<ReasoningEffort>,
2960    /// Optional request overrides (headers/body patches/model rules).
2961    #[serde(default, skip_serializing_if = "Option::is_none")]
2962    pub request_overrides: Option<RequestOverridesConfig>,
2963
2964    /// Unconditionally replay a prior turn's `reasoning` as a `thinking`
2965    /// content block, regardless of whether bamboo captured a valid signature
2966    /// for it (issue #520).
2967    ///
2968    /// Defaults to `false`/absent, which is REQUIRED for real Anthropic: it
2969    /// requires `thinking` input blocks to carry a signature it minted itself,
2970    /// and bamboo never captures one, so an unconditionally-replayed block is
2971    /// always rejected with a 400 (either because it's foreign — minted by a
2972    /// different provider after a mid-session model switch — or because it's
2973    /// an unsigned copy of Claude's own prior turn).
2974    ///
2975    /// Set this to `true` only when pointing `base_url` at an
2976    /// Anthropic-COMPATIBLE upstream (e.g. GLM's `/anthropic` endpoint) that
2977    /// has the opposite contract: it requires the `thinking` block to be
2978    /// present whenever thinking is enabled, but never validates its
2979    /// signature.
2980    #[serde(default, skip_serializing_if = "Option::is_none")]
2981    pub thinking_replay_always: Option<bool>,
2982
2983    /// Preserve unknown keys under `providers.anthropic`.
2984    #[serde(default, flatten)]
2985    pub extra: BTreeMap<String, Value>,
2986}
2987
2988/// Google Gemini provider configuration
2989///
2990/// # Example
2991///
2992/// ```json
2993/// "gemini": {
2994///   "api_key": "AIza...",
2995///   "model": "gemini-2.0-flash-exp"
2996/// }
2997/// ```
2998#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2999pub struct GeminiConfig {
3000    /// Google AI API key (plaintext, in-memory only).
3001    ///
3002    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3003    #[serde(default, skip_serializing)]
3004    pub api_key: String,
3005    /// Encrypted Google AI API key (nonce:ciphertext).
3006    #[serde(default, skip_serializing_if = "Option::is_none")]
3007    pub api_key_encrypted: Option<String>,
3008    /// Stable reference to the isolated credential store.
3009    #[serde(default, skip_serializing_if = "Option::is_none")]
3010    pub credential_ref: Option<crate::CredentialRef>,
3011    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3012    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3013    /// `api_key_encrypted` on save (that would bake the secret into
3014    /// config.json). Not (de)serialized. (#253)
3015    #[serde(skip)]
3016    pub api_key_from_env: bool,
3017    /// Custom API base URL
3018    #[serde(skip_serializing_if = "Option::is_none")]
3019    pub base_url: Option<String>,
3020    /// Default model to use (e.g., "gemini-2.0-flash-exp")
3021    #[serde(skip_serializing_if = "Option::is_none")]
3022    pub model: Option<String>,
3023    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3024    /// Falls back to `model` when not set.
3025    #[serde(default, skip_serializing_if = "Option::is_none")]
3026    pub fast_model: Option<String>,
3027    /// Vision-capable model for image understanding tasks.
3028    /// Falls back to `model` when not set.
3029    #[serde(default, skip_serializing_if = "Option::is_none")]
3030    pub vision_model: Option<String>,
3031    /// Default reasoning effort for Gemini requests.
3032    #[serde(skip_serializing_if = "Option::is_none")]
3033    pub reasoning_effort: Option<ReasoningEffort>,
3034    /// Optional request overrides (headers/body patches/model rules).
3035    #[serde(default, skip_serializing_if = "Option::is_none")]
3036    pub request_overrides: Option<RequestOverridesConfig>,
3037
3038    /// Preserve unknown keys under `providers.gemini`.
3039    #[serde(default, flatten)]
3040    pub extra: BTreeMap<String, Value>,
3041}
3042
3043/// GitHub Copilot provider configuration
3044///
3045/// # Example
3046///
3047/// ```json
3048/// "copilot": {
3049///   "enabled": true,
3050///   "headless_auth": false,
3051///   "model": "gpt-4o"
3052/// }
3053/// ```
3054#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3055pub struct CopilotConfig {
3056    /// Whether Copilot provider is enabled
3057    #[serde(default)]
3058    pub enabled: bool,
3059    /// Print login URL to console instead of opening browser
3060    #[serde(default)]
3061    pub headless_auth: bool,
3062    /// Default model to use for Copilot (used when clients request the "default" model)
3063    #[serde(skip_serializing_if = "Option::is_none")]
3064    pub model: Option<String>,
3065    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3066    /// Falls back to `model` when not set.
3067    #[serde(default, skip_serializing_if = "Option::is_none")]
3068    pub fast_model: Option<String>,
3069    /// Vision-capable model for image understanding tasks.
3070    /// Falls back to `model` when not set.
3071    #[serde(default, skip_serializing_if = "Option::is_none")]
3072    pub vision_model: Option<String>,
3073    /// Default reasoning effort for Copilot requests.
3074    #[serde(skip_serializing_if = "Option::is_none")]
3075    pub reasoning_effort: Option<ReasoningEffort>,
3076
3077    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3078    ///
3079    /// This is useful for newer Copilot models that only support Responses-style requests.
3080    ///
3081    /// Example:
3082    /// ```json
3083    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3084    /// ```
3085    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3086    pub responses_only_models: Vec<String>,
3087    /// Optional request overrides (headers/body patches/model rules).
3088    #[serde(default, skip_serializing_if = "Option::is_none")]
3089    pub request_overrides: Option<RequestOverridesConfig>,
3090
3091    /// Preserve unknown keys under `providers.copilot`.
3092    #[serde(default, flatten)]
3093    pub extra: BTreeMap<String, Value>,
3094}
3095
3096/// Bodhi proxy provider configuration.
3097///
3098/// Routes LLM requests through a bodhi-server instance so that raw provider
3099/// API keys never reach the client.
3100#[derive(Debug, Clone, Serialize, Deserialize)]
3101pub struct BodhiConfig {
3102    /// Bodhi server API key (e.g. "bhi_sk_xxx").  In-memory only.
3103    #[serde(default, skip_serializing)]
3104    pub api_key: String,
3105    /// Encrypted form of the API key stored on disk.
3106    #[serde(default, skip_serializing_if = "Option::is_none")]
3107    pub api_key_encrypted: Option<String>,
3108    /// Stable reference to the isolated credential store.
3109    #[serde(default, skip_serializing_if = "Option::is_none")]
3110    pub credential_ref: Option<crate::CredentialRef>,
3111    /// Bodhi server base URL.
3112    #[serde(skip_serializing_if = "Option::is_none")]
3113    pub base_url: Option<String>,
3114    /// Which upstream provider to route through bodhi ("openai", "anthropic", "gemini").
3115    #[serde(skip_serializing_if = "Option::is_none")]
3116    pub target_provider: Option<String>,
3117    /// Default reasoning effort.
3118    #[serde(skip_serializing_if = "Option::is_none")]
3119    pub reasoning_effort: Option<ReasoningEffort>,
3120
3121    /// Preserve unknown keys.
3122    #[serde(default, flatten)]
3123    pub extra: BTreeMap<String, Value>,
3124}
3125
3126/// Returns the default provider name ("anthropic")
3127fn default_provider() -> String {
3128    "anthropic".to_string()
3129}
3130
3131// ─── Provider Instance Configuration ──────────────────────────────────
3132
3133/// Configuration for a single provider instance.
3134///
3135/// Multiple instances of the same provider type (e.g. two OpenAI accounts)
3136/// can coexist. Each instance is identified by a stable `instance_id` that
3137/// is used as the routing key in [`ProviderModelRef::provider`] and the
3138/// provider registry.
3139///
3140/// # Example (config.json)
3141///
3142/// ```json
3143/// {
3144///   "provider_instances": {
3145///     "openai-work": {
3146///       "provider_type": "openai",
3147///       "label": "OpenAI (Work)",
3148///       "api_key": "sk-...",
3149///       "model": "gpt-4o"
3150///     },
3151///     "openai-personal": {
3152///       "provider_type": "openai",
3153///       "label": "OpenAI (Personal)",
3154///       "api_key": "sk-...",
3155///       "base_url": "https://api.openai.com/v1",
3156///       "model": "gpt-4o-mini"
3157///     }
3158///   },
3159///   "default_provider_instance": "openai-work"
3160/// }
3161/// ```
3162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3163pub struct ProviderInstanceConfig {
3164    /// Which provider backend this instance targets.
3165    ///
3166    /// Must be one of [`AVAILABLE_PROVIDERS`]: `"openai"`, `"anthropic"`,
3167    /// `"gemini"`, `"copilot"`, `"bodhi"`.
3168    pub provider_type: String,
3169
3170    /// Human-readable label shown in the UI / catalog.
3171    #[serde(default, skip_serializing_if = "Option::is_none")]
3172    pub label: Option<String>,
3173
3174    /// API key (plaintext in memory, encrypted at rest via `api_key_encrypted`).
3175    #[serde(default, skip_serializing)]
3176    pub api_key: String,
3177
3178    /// Encrypted API key (nonce:ciphertext). Written to disk; decrypted into
3179    /// `api_key` on load.
3180    #[serde(default, skip_serializing_if = "Option::is_none")]
3181    pub api_key_encrypted: Option<String>,
3182
3183    /// Stable reference to the isolated credential store.
3184    #[serde(default, skip_serializing_if = "Option::is_none")]
3185    pub credential_ref: Option<crate::CredentialRef>,
3186
3187    /// Custom base URL override.
3188    #[serde(default, skip_serializing_if = "Option::is_none")]
3189    pub base_url: Option<String>,
3190
3191    /// Default chat model for this instance.
3192    #[serde(default, skip_serializing_if = "Option::is_none")]
3193    pub model: Option<String>,
3194
3195    /// Fast/cheap model for lightweight tasks.
3196    #[serde(default, skip_serializing_if = "Option::is_none")]
3197    pub fast_model: Option<String>,
3198
3199    /// Vision-capable model.
3200    #[serde(default, skip_serializing_if = "Option::is_none")]
3201    pub vision_model: Option<String>,
3202
3203    /// Default reasoning effort.
3204    #[serde(default, skip_serializing_if = "Option::is_none")]
3205    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3206
3207    /// Models that must use the Responses API upstream (OpenAI only).
3208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3209    pub responses_only_models: Vec<String>,
3210
3211    /// Optional request overrides (headers/body patches/model rules).
3212    #[serde(default, skip_serializing_if = "Option::is_none")]
3213    pub request_overrides: Option<RequestOverridesConfig>,
3214
3215    /// Whether this instance is enabled. Disabled instances are skipped
3216    /// during registry construction.
3217    #[serde(default = "default_true")]
3218    pub enabled: bool,
3219
3220    /// Provider-type-specific extra fields preserved through (de)serialization.
3221    #[serde(default, flatten)]
3222    pub extra: BTreeMap<String, Value>,
3223}
3224
3225fn default_true() -> bool {
3226    true
3227}
3228
3229/// Returns the default server port (9562)
3230fn default_port() -> u16 {
3231    9562
3232}
3233
3234/// Returns the default bind address (127.0.0.1)
3235fn default_bind() -> String {
3236    "127.0.0.1".to_string()
3237}
3238
3239/// Returns the default worker count (10)
3240fn default_workers() -> usize {
3241    10
3242}
3243
3244/// Returns the default data directory (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3245fn default_data_dir() -> PathBuf {
3246    super::paths::bamboo_dir()
3247}
3248
3249/// HTTP server configuration
3250#[derive(Debug, Clone, Serialize, Deserialize)]
3251pub struct ServerConfig {
3252    /// Port to listen on
3253    #[serde(default = "default_port")]
3254    pub port: u16,
3255
3256    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
3257    #[serde(default = "default_bind")]
3258    pub bind: String,
3259
3260    /// Static files directory (for Docker mode)
3261    pub static_dir: Option<PathBuf>,
3262
3263    /// Worker count for Actix-web
3264    #[serde(default = "default_workers")]
3265    pub workers: usize,
3266
3267    /// v2 (API v2 transport, #181): optional TLS termination config. When both
3268    /// `cert_file` and `key_file` are given, bamboo terminates TLS itself
3269    /// (rustls, no reverse proxy) and serves `https://` — intended for the
3270    /// public `0.0.0.0` face. When absent, the server keeps the plain `.bind()`
3271    /// / `.listen()` path unchanged (desktop loopback stays plaintext). Missing
3272    /// or unparseable cert/key files are fail-fast at startup, never a silent
3273    /// downgrade to plaintext.
3274    #[serde(default, skip_serializing_if = "Option::is_none")]
3275    pub tls: Option<TlsConfig>,
3276
3277    /// Preserve unknown keys under `server`.
3278    #[serde(default, flatten)]
3279    pub extra: BTreeMap<String, Value>,
3280}
3281
3282impl Default for ServerConfig {
3283    fn default() -> Self {
3284        Self {
3285            port: default_port(),
3286            bind: default_bind(),
3287            static_dir: None,
3288            workers: default_workers(),
3289            tls: None,
3290            extra: BTreeMap::new(),
3291        }
3292    }
3293}
3294
3295/// Manual TLS certificate configuration (current stage; ACME deferred).
3296///
3297/// Both fields point at PEM files: `cert_file` is the full certificate chain
3298/// (leaf → intermediates → root), `key_file` is the matching private key
3299/// (PKCS#8 or RSA). See `docs/api-v2-transport.md` §3.
3300#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3301pub struct TlsConfig {
3302    /// PEM certificate chain (leaf → intermediates → root).
3303    pub cert_file: PathBuf,
3304    /// PEM private key (PKCS#8 or RSA).
3305    pub key_file: PathBuf,
3306}
3307
3308/// Proxy authentication credentials
3309#[derive(Debug, Clone, Serialize, Deserialize)]
3310pub struct ProxyAuth {
3311    /// Proxy username
3312    pub username: String,
3313    /// Proxy password
3314    pub password: String,
3315}
3316
3317/// Parse a boolean value from environment variable strings
3318///
3319/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
3320fn parse_bool_env(value: &str) -> bool {
3321    matches!(
3322        value.trim().to_ascii_lowercase().as_str(),
3323        "1" | "true" | "yes" | "y" | "on"
3324    )
3325}
3326
3327fn expand_user_path(value: &str) -> PathBuf {
3328    let trimmed = value.trim();
3329    if let Some(rest) = trimmed.strip_prefix("~/") {
3330        if let Some(home) = dirs::home_dir() {
3331            return home.join(rest);
3332        }
3333    }
3334    PathBuf::from(trimmed)
3335}
3336
3337impl Default for Config {
3338    fn default() -> Self {
3339        // In-memory defaults ONLY. `default()` must not touch the filesystem or
3340        // environment: it was delegating to `new()` → `from_data_dir(None)`,
3341        // which read config.json from disk, applied BAMBOO_* env overrides, and
3342        // published to the global env-var cache. That made every `..Default::
3343        // default()` struct-update and every test silently disk-dependent and
3344        // let non-server callers clobber the server's in-memory config cache.
3345        // `create_default()` is the pure in-memory constructor; disk loading is
3346        // the explicit job of `new()` / `from_data_dir()`. #38.
3347        Self::create_default()
3348    }
3349}
3350
3351/// Prompt-safe snapshot of configured env vars.
3352#[derive(Debug, Clone, PartialEq, Eq)]
3353pub struct PromptSafeEnvVarEntry {
3354    pub name: String,
3355    pub secret: bool,
3356    pub description: Option<String>,
3357}
3358
3359/// Global cache of user-managed env vars for injection into child processes.
3360///
3361/// Updated whenever the config is loaded or reloaded via [`Config::publish_env_vars`].
3362static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3363
3364static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3365
3366fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3367    ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3368}
3369
3370fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3371    PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3372}
3373
3374impl Config {
3375    fn from_parts(
3376        values: ConfigValues,
3377        memory: Option<MemoryConfig>,
3378        subagents: SubagentsConfig,
3379        providers: ProviderConfigs,
3380    ) -> Self {
3381        Self {
3382            values,
3383            memory: crate::MemoryConfigModule(memory),
3384            subagents: crate::SubagentsConfigModule(subagents),
3385            providers: crate::ProviderConfigsModule(providers),
3386            recovery_status: None,
3387        }
3388    }
3389
3390    /// Crate-internal seam used by the modular section facade.
3391    ///
3392    /// Keeping this conversion here lets the facade exhaustively destructure
3393    /// [`ConfigValues`] in both directions without exposing the compatibility
3394    /// facade's private storage to downstream crates.
3395    pub(crate) fn section_values(&self) -> ConfigValues {
3396        self.values.clone()
3397    }
3398
3399    pub(crate) fn from_section_parts(
3400        values: ConfigValues,
3401        memory: Option<MemoryConfig>,
3402        subagents: SubagentsConfig,
3403        providers: ProviderConfigs,
3404    ) -> Self {
3405        Self::from_parts(values, memory, subagents, providers)
3406    }
3407
3408    /// Compatibility accessor for independently persisted memory settings.
3409    pub fn memory(&self) -> &Option<MemoryConfig> {
3410        &self.memory.0
3411    }
3412
3413    pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3414        &mut self.memory.0
3415    }
3416
3417    /// Compatibility accessor for independently persisted sub-agent settings.
3418    pub fn subagents(&self) -> &SubagentsConfig {
3419        &self.subagents.0
3420    }
3421
3422    pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3423        &mut self.subagents.0
3424    }
3425
3426    /// Compatibility accessor for independently persisted legacy providers.
3427    pub fn providers(&self) -> &ProviderConfigs {
3428        &self.providers.0
3429    }
3430
3431    pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3432        &mut self.providers.0
3433    }
3434
3435    /// Canonicalize only the durable provider view when instance routing is
3436    /// authoritative. Unknown provider entries remain available for forward
3437    /// compatibility.
3438    pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3439        if self
3440            .default_provider_instance
3441            .as_ref()
3442            .is_some_and(|id| self.provider_instances.contains_key(id))
3443        {
3444            self.providers.0.clear_legacy_builtin_aliases();
3445        }
3446    }
3447
3448    /// Build the legacy full-config JSON view used by in-memory patching APIs.
3449    ///
3450    /// Public serde and patch/dot-path callers retain the historical full
3451    /// configuration shape. This value is never the persistence representation:
3452    /// [`Config::save_to_dir`] explicitly writes a root-only DTO plus sidecars.
3453    pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3454        let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3455        let object = value
3456            .as_object_mut()
3457            .expect("ConfigRoot always serializes as a JSON object");
3458        object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3459        object.insert(
3460            "subagents".to_string(),
3461            serde_json::to_value(self.subagents())?,
3462        );
3463        object.insert(
3464            "providers".to_string(),
3465            serde_json::to_value(self.providers())?,
3466        );
3467        Ok(value)
3468    }
3469
3470    /// Load configuration from file with environment variable overrides
3471    ///
3472    /// Configuration loading order:
3473    /// 1. Try loading from `config.json` (`{data_dir}/config.json`)
3474    /// 2. Use defaults
3475    /// 3. Apply environment variable overrides (highest priority)
3476    ///
3477    /// # Environment Variables
3478    ///
3479    /// - `BAMBOO_PORT`: Override server port
3480    /// - `BAMBOO_BIND`: Override bind address
3481    /// - `BAMBOO_DATA_DIR`: Override data directory
3482    /// - `BAMBOO_PROVIDER`: Override default provider
3483    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
3484    /// - `BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION`: Override project durable-memory index prompt injection
3485    /// - `BAMBOO_MEMORY_RELEVANT_RECALL`: Override relevant durable-memory recall prompt injection
3486    /// - `BAMBOO_MEMORY_RELEVANT_RECALL_RERANK`: Override model-based relevant recall reranking
3487    /// - `BAMBOO_MEMORY_PROJECT_FIRST_DREAM`: Override project-first Dream prompt behavior
3488    pub fn new() -> Self {
3489        Self::from_data_dir(None)
3490    }
3491
3492    fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3493        let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3494            Ok(facade) => facade.effective_config(),
3495            Err(error) => {
3496                tracing::warn!(
3497                    error = %error,
3498                    "completed modular configuration is unavailable; refusing legacy-root fallback"
3499                );
3500                Self::create_default()
3501            }
3502        };
3503
3504        if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3505            tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3506            config.proxy_auth = None;
3507        }
3508        if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3509            tracing::warn!(error = %error, "provider credential hydration unavailable");
3510        }
3511        if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3512            tracing::warn!(error = %error, "MCP credential hydration unavailable");
3513        }
3514        if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3515            tracing::warn!(error = %error, "env credential hydration unavailable");
3516            for entry in &mut config.env_vars {
3517                if entry.secret {
3518                    entry.value.clear();
3519                }
3520            }
3521        }
3522        if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3523            tracing::warn!(error = %error, "cluster credential hydration unavailable");
3524            config.clear_cluster_runtime_credentials();
3525        }
3526        if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3527            tracing::warn!(error = %error, "notification credential hydration unavailable");
3528            config.notifications.ntfy.token = None;
3529            config.notifications.bark.device_key = None;
3530        }
3531        if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3532            tracing::warn!(error = %error, "connect credential hydration unavailable");
3533            for platform in &mut config.connect.platforms {
3534                platform.token = None;
3535                platform.app_secret = None;
3536            }
3537        }
3538        if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3539            tracing::warn!(error = %error, "access-control credential hydration unavailable");
3540            config.clear_access_control_runtime_verifiers();
3541        }
3542        if let Some(broker) = config.subagents_mut().broker.as_mut() {
3543            if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3544                tracing::warn!(error = %error, "external broker credential hydration unavailable");
3545                broker.token.clear();
3546            }
3547        }
3548        config.normalize_tool_settings();
3549        config.normalize_skill_settings();
3550        config.normalize_plugin_trust_settings();
3551        config.extra.remove("data_dir");
3552        if apply_env {
3553            config.apply_env_overrides();
3554        }
3555        if publish {
3556            config.publish_env_vars();
3557        }
3558        config
3559    }
3560
3561    /// Load configuration from a specific data directory.
3562    ///
3563    /// Use [`Config::from_data_dir`] (publishes env vars to the global cache, for
3564    /// the context that OWNS the cache — the server bootstrap) or
3565    /// [`Config::from_data_dir_without_publish`] (for non-owning readers that must
3566    /// not clobber the live cache). #40.
3567    ///
3568    /// * `data_dir` - Optional data directory path. If None, uses default (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3569    fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3570        // Determine data_dir early (needed to find config file)
3571        let data_dir = data_dir
3572            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3573            .unwrap_or_else(default_data_dir);
3574
3575        match crate::modular_authority_boundary_present(&data_dir) {
3576            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3577            Ok(false) => {}
3578            Err(error) => {
3579                tracing::warn!(
3580                    error = %error,
3581                    "modular configuration marker is unavailable; refusing legacy-root fallback"
3582                );
3583                let mut config = Self::create_default();
3584                if apply_env {
3585                    config.apply_env_overrides();
3586                }
3587                if publish {
3588                    config.publish_env_vars();
3589                }
3590                return config;
3591            }
3592        }
3593
3594        // Finish any shared manifest-committed credential extraction before
3595        // reading even one member of the transaction, then plan only the
3596        // provider/MCP/root domains. The optional broker has its own planner so
3597        // malformed broker metadata cannot suppress main configuration loading.
3598        let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3599            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3600            .map_err(|error| {
3601                tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3602                error
3603            })
3604            .is_ok();
3605        let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3606            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3607            .map_err(|error| {
3608                tracing::warn!(error = %error, "cluster credential migration unavailable");
3609                error
3610            })
3611            .is_ok();
3612
3613        // A recovery performed by either legacy credential planner may have
3614        // completed the modular split. Reclassify immediately before touching
3615        // config.json so a pending split cannot race this compatibility load
3616        // back into legacy authority.
3617        match crate::modular_authority_boundary_present(&data_dir) {
3618            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3619            Ok(false) => {}
3620            Err(error) => {
3621                tracing::warn!(
3622                    error = %error,
3623                    "modular configuration boundary is unavailable; refusing legacy-root fallback"
3624                );
3625                let mut config = Self::create_default();
3626                if apply_env {
3627                    config.apply_env_overrides();
3628                }
3629                if publish {
3630                    config.publish_env_vars();
3631                }
3632                return config;
3633            }
3634        }
3635
3636        let config_path = data_dir.join("config.json");
3637
3638        let mut config = if config_path.exists() {
3639            if let Ok(content) = std::fs::read_to_string(&config_path) {
3640                Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3641                    // Don't silently discard the user's config on corruption.
3642                    // Quarantine the unparseable file, then recover the MOST recent
3643                    // intent in order: (1) SALVAGE the still-valid fields from the
3644                    // corrupt file (a single bad field shouldn't drop everything),
3645                    // (2) the last-known-good config.json.bak, (3) defaults.
3646                    // #37 / #135. Tag the recovered config with a
3647                    // ConfigRecoveryStatus (unconfirmed) so save_to_dir refuses to
3648                    // overwrite config.json until the caller confirms — the
3649                    // quarantined original stays hand-recoverable until then. #153.
3650                    tracing::warn!(
3651                        "Failed to parse config.json ({}); quarantining it and attempting recovery",
3652                        e
3653                    );
3654                    let quarantine_path = quarantine_corrupt_config(&config_path);
3655                    let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3656                        .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3657                        .or_else(|| {
3658                            Self::load_backup(&data_dir).map(|(cfg, generation)| {
3659                                (cfg, ConfigRecoverySource::Backup { generation })
3660                            })
3661                        })
3662                        .unwrap_or_else(|| {
3663                            tracing::warn!(
3664                                "Could not salvage and no usable config.json.bak; using defaults"
3665                            );
3666                            (Self::create_default(), ConfigRecoverySource::Defaults)
3667                        });
3668                    recovered.recovery_status = Some(ConfigRecoveryStatus {
3669                        source,
3670                        quarantine_path,
3671                        confirmed: false,
3672                    });
3673                    recovered
3674                })
3675            } else {
3676                Self::create_default()
3677            }
3678        } else {
3679            Self::create_default()
3680        };
3681
3682        // Phase-1 registrar migration: an existing sidecar is authoritative;
3683        // when absent, retain the legacy inline value loaded from config.json.
3684        // A malformed sidecar is never rewritten during load and the inline
3685        // value remains available, preventing a bad independent edit from
3686        // erasing the user's last usable configuration.
3687        let mut memory_module = config.memory.clone();
3688        match memory_module.load_sync(&data_dir) {
3689            Ok(true) => config.memory = memory_module,
3690            Ok(false) => {}
3691            Err(error) => tracing::warn!(
3692                "Failed to load memory.json; using legacy config.json memory: {error}"
3693            ),
3694        }
3695        let mut subagents_module = config.subagents.clone();
3696        match subagents_module.load_sync(&data_dir) {
3697            Ok(true) => config.subagents = subagents_module,
3698            Ok(false) => {}
3699            Err(error) => tracing::warn!(
3700                "Failed to load subagents.json; using legacy config.json subagents: {error}"
3701            ),
3702        }
3703        if provider_mcp_ready {
3704            let mut providers_module = config.providers.clone();
3705            match providers_module.load_sync(&data_dir) {
3706                Ok(true) => config.providers = providers_module,
3707                Ok(false) => {}
3708                Err(error) => tracing::warn!(
3709                    "Failed to load providers.json; using legacy config.json providers: {error}"
3710                ),
3711            }
3712        }
3713
3714        // Decrypt encrypted proxy auth into in-memory plaintext form.
3715        config.hydrate_proxy_auth_from_encrypted();
3716        if provider_mcp_ready {
3717            if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
3718                tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3719                config.proxy_auth = None;
3720            }
3721        }
3722        // Decrypt encrypted provider API keys into in-memory plaintext form.
3723        config.hydrate_provider_api_keys_from_encrypted();
3724        // Decrypt encrypted provider-instance API keys into in-memory plaintext form.
3725        config.hydrate_provider_instance_api_keys_from_encrypted();
3726        // Decrypt encrypted MCP secrets into in-memory plaintext form.
3727        config.hydrate_mcp_secrets_from_encrypted();
3728        if provider_mcp_ready {
3729            if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
3730                tracing::warn!(error = %error, "provider credential hydration unavailable");
3731            }
3732            if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
3733                tracing::warn!(error = %error, "MCP credential hydration unavailable");
3734            }
3735        }
3736        // Decrypt encrypted env vars into in-memory plaintext form.
3737        config.hydrate_env_vars_from_encrypted();
3738        if provider_mcp_ready {
3739            if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
3740                tracing::warn!(error = %error, "env credential hydration unavailable");
3741                for entry in &mut config.env_vars {
3742                    if entry.secret {
3743                        entry.value.clear();
3744                    }
3745                }
3746            }
3747        }
3748        // Cluster migration is deliberately independent from provider/MCP
3749        // readiness. A malformed optional fabric fails only cluster runtime
3750        // authentication, while missing/corrupt refs never fall back to legacy
3751        // ciphertext or an unauthenticated SSH attempt.
3752        if cluster_ready {
3753            if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
3754                tracing::warn!(error = %error, "cluster credential hydration unavailable");
3755                config.clear_cluster_runtime_credentials();
3756            }
3757        } else {
3758            config.clear_cluster_runtime_credentials();
3759        }
3760        // Decrypt the encrypted broker token into in-memory plaintext.
3761        config.hydrate_broker_token_from_encrypted();
3762        // Decrypt encrypted notification-channel secrets into in-memory plaintext.
3763        config.hydrate_notifications_from_encrypted();
3764        if provider_mcp_ready {
3765            if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
3766                tracing::warn!(error = %error, "notification credential hydration unavailable");
3767                config.notifications.ntfy.token = None;
3768                config.notifications.bark.device_key = None;
3769            }
3770        } else {
3771            // A pending or unreadable credential migration means the isolated
3772            // store is not authoritative yet. Legacy plaintext/ciphertext may
3773            // remain on disk for recovery, but notification sinks must not use
3774            // it in this process.
3775            config.notifications.ntfy.token = None;
3776            config.notifications.bark.device_key = None;
3777        }
3778        // Merge the standalone connect.json (#455) onto `config.connect`,
3779        // migrating a legacy inline `connect` key from config.json (#453
3780        // state) when present. MUST run before the token hydration below so
3781        // it decrypts the post-merge ciphertext, not a stale/legacy copy.
3782        config.merge_connect_config(&data_dir);
3783        // One-time (idempotent) sweep of the rotated config.json.bak[.N]
3784        // generations for a legacy embedded `connect` sub-tree left behind by
3785        // a pre-#455 build (#468, follow-up to #457). Independent of whether
3786        // `merge_connect_config` just migrated the CURRENT config.json above —
3787        // an instance that was already migrated by an earlier run of this
3788        // binary has a clean config.json today but may still carry the
3789        // legacy key in an untouched `.bak`/`.bak.1`/`.bak.2` generation, since
3790        // backup rotation only overwrites those on a fresh SAVE. Runs on every
3791        // load but is cheap and a no-op once every generation has been swept.
3792        scrub_legacy_connect_from_config_backups(&data_dir);
3793        // Decrypt encrypted bamboo-connect platform tokens into in-memory plaintext.
3794        config.hydrate_connect_platform_tokens_from_encrypted();
3795        if provider_mcp_ready {
3796            if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
3797                tracing::warn!(error = %error, "connect credential hydration unavailable");
3798                for platform in &mut config.connect.platforms {
3799                    platform.token = None;
3800                    platform.app_secret = None;
3801                }
3802            }
3803        }
3804        if provider_mcp_ready {
3805            if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
3806                tracing::warn!(error = %error, "access-control credential hydration unavailable");
3807                config.clear_access_control_runtime_verifiers();
3808            }
3809        } else {
3810            config.clear_access_control_runtime_verifiers();
3811        }
3812        config.normalize_tool_settings();
3813        config.normalize_skill_settings();
3814        config.normalize_plugin_trust_settings();
3815
3816        // Legacy: `data_dir` is no longer a persisted config field. The data directory is
3817        // derived from runtime (BAMBOO_DATA_DIR or `${HOME}/.bamboo`).
3818        config.extra.remove("data_dir");
3819
3820        // Apply environment variable overrides (highest priority). Skipped by
3821        // one-shot CLI writers (`bamboo init` / `config set`) so transient
3822        // `BAMBOO_*` values are never baked into the persisted config.json.
3823        if apply_env {
3824            config.apply_env_overrides();
3825        }
3826
3827        // Publish env vars to the global cache so Bash tools can inject them —
3828        // ONLY when the caller owns that cache. Non-owning readers pass
3829        // publish=false so they don't clobber the server's live env-var cache.
3830        if publish {
3831            config.publish_env_vars();
3832        }
3833
3834        config
3835    }
3836
3837    /// Apply `BAMBOO_*` environment overrides (highest priority) onto a loaded
3838    /// config. Factored out so one-shot writers can skip it (see
3839    /// [`Config::from_data_dir_without_env`]).
3840    fn apply_env_overrides(&mut self) {
3841        if let Ok(port) = std::env::var("BAMBOO_PORT") {
3842            if let Ok(port) = port.parse() {
3843                self.server.port = port;
3844            }
3845        }
3846
3847        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
3848            self.server.bind = bind;
3849        }
3850
3851        // Note: BAMBOO_DATA_DIR already handled by the caller. In instance
3852        // mode the override may name either an exact instance id or a provider
3853        // type. Type matches are ordered by instance id so the result is
3854        // deterministic even for a multi-account configuration.
3855        if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
3856            let provider = provider.trim().to_string();
3857            if !provider.is_empty() {
3858                self.provider = provider.clone();
3859                if !self.provider_instances.is_empty() {
3860                    let selected = self
3861                        .provider_instances
3862                        .contains_key(&provider)
3863                        .then_some(provider.clone())
3864                        .or_else(|| {
3865                            let mut matching = self
3866                                .provider_instances
3867                                .iter()
3868                                .filter(|(_, instance)| {
3869                                    instance.enabled && instance.provider_type == provider
3870                                })
3871                                .map(|(id, _)| id.clone())
3872                                .collect::<Vec<_>>();
3873                            matching.sort();
3874                            matching.into_iter().next()
3875                        });
3876                    self.default_provider_instance = selected;
3877                }
3878            }
3879        }
3880
3881        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
3882            self.headless_auth = parse_bool_env(&headless);
3883        }
3884
3885        if let Ok(project_prompt_injection) =
3886            std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
3887        {
3888            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3889            memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
3890        }
3891
3892        if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
3893            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3894            memory.relevant_recall = parse_bool_env(&relevant_recall);
3895        }
3896
3897        if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
3898            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3899            memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
3900        }
3901
3902        if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
3903            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
3904            memory.project_first_dream = parse_bool_env(&project_first_dream);
3905        }
3906
3907        // Per-provider API keys from the environment (highest priority). Lets a
3908        // 12-factor / secret-manager / --env-file / k8s-Secret deploy supply the
3909        // key at runtime instead of baking a plaintext `api_key` into a mounted
3910        // config.json. The `api_key_from_env` flag keeps `refresh_provider_api_keys_encrypted`
3911        // from re-encrypting these keys into `api_key_encrypted` on a later save,
3912        // so an env key is never persisted to disk. (#253)
3913        if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
3914            let key = key.trim();
3915            if !key.is_empty() {
3916                if self.provider_instances.is_empty() {
3917                    let openai = self
3918                        .providers
3919                        .openai
3920                        .get_or_insert_with(OpenAIConfig::default);
3921                    openai.api_key = key.to_string();
3922                    openai.api_key_from_env = true;
3923                } else {
3924                    self.apply_provider_instance_env_key("openai", key);
3925                }
3926            }
3927        }
3928        if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
3929            let key = key.trim();
3930            if !key.is_empty() {
3931                if self.provider_instances.is_empty() {
3932                    let anthropic = self
3933                        .providers
3934                        .anthropic
3935                        .get_or_insert_with(AnthropicConfig::default);
3936                    anthropic.api_key = key.to_string();
3937                    anthropic.api_key_from_env = true;
3938                } else {
3939                    self.apply_provider_instance_env_key("anthropic", key);
3940                }
3941            }
3942        }
3943        if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
3944            let key = key.trim();
3945            if !key.is_empty() {
3946                if self.provider_instances.is_empty() {
3947                    let gemini = self
3948                        .providers
3949                        .gemini
3950                        .get_or_insert_with(GeminiConfig::default);
3951                    gemini.api_key = key.to_string();
3952                    gemini.api_key_from_env = true;
3953                } else {
3954                    self.apply_provider_instance_env_key("gemini", key);
3955                }
3956            }
3957        }
3958    }
3959
3960    fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
3961        for instance in self.provider_instances.values_mut().filter(|instance| {
3962            instance.provider_type == provider_type
3963                && crate::provider_instance_api_key_from_env(instance)
3964        }) {
3965            instance.api_key = key.to_string();
3966            instance.api_key_encrypted = None;
3967        }
3968    }
3969
3970    /// Apply runtime-only `BAMBOO_*` overrides to a config assembled by the
3971    /// modular facade. Facade callers use this after durable section snapshots
3972    /// and credential references have been materialized; one-shot writers keep
3973    /// using the no-env load path so overrides are never persisted.
3974    pub fn apply_runtime_env_overrides(&mut self) {
3975        self.apply_env_overrides();
3976    }
3977
3978    /// Load config from disk AND publish its env vars to the process-global cache
3979    /// (so Bash tools inject them). For the context that OWNS that cache — the
3980    /// server bootstrap. Library / secondary readers that only need to read a
3981    /// value must use [`Config::from_data_dir_without_publish`] instead, or they
3982    /// will clobber the server's live cache with stale disk data (#38 / #40).
3983    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
3984        Self::from_data_dir_impl(data_dir, true, true)
3985    }
3986
3987    /// Load config from disk WITHOUT publishing env vars to the global cache.
3988    /// For non-owning readers (e.g. permission storage) that just need a config
3989    /// value and must not clobber the live env-var cache. #40.
3990    pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
3991        Self::from_data_dir_impl(data_dir, false, true)
3992    }
3993
3994    /// Load config from disk WITHOUT applying `BAMBOO_*` env-var overrides and
3995    /// WITHOUT publishing to the global cache. For one-shot CLI writers
3996    /// (`bamboo init` / `config set`) that immediately re-save: applying env
3997    /// overrides here would bake transient values (port/bind/provider/memory
3998    /// flags) permanently into config.json. Same corruption-recovery + default
3999    /// fallback as the normal load.
4000    pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4001        Self::from_data_dir_impl(data_dir, false, false)
4002    }
4003
4004    /// Merge the standalone `connect.json` (#455) onto `self.connect`, the
4005    /// load-side counterpart of [`save_connect_config`]. Called once per load,
4006    /// BEFORE [`Config::hydrate_connect_platform_tokens_from_encrypted`] runs,
4007    /// so hydration decrypts the POST-merge ciphertext rather than a stale
4008    /// copy still embedded in `config.json`.
4009    ///
4010    /// - `connect.json` present & parseable: authoritative — OVERWRITES
4011    ///   whatever `self.connect` currently holds. If `self.connect` was ALSO
4012    ///   non-empty (a legacy inline `connect` key still in config.json, e.g.
4013    ///   #453-era state, or written by an older binary), that's a stale
4014    ///   duplicate: log a warning and proactively strip the superseded key
4015    ///   from config.json now (#457) rather than waiting for the next
4016    ///   natural save — cheap, and consistent with not spreading token
4017    ///   ciphertext across files.
4018    /// - `connect.json` present but corrupt/unparsable: fail SAFE for this
4019    ///   security-sensitive feature. Log an error, quarantine the bad file to
4020    ///   `connect.json.bak` (best-effort), and continue with an EMPTY
4021    ///   `ConnectConfig` — never falls back to a legacy config.json copy.
4022    /// - `connect.json` absent & `self.connect` non-empty (pure legacy
4023    ///   state): migrate proactively. Adopt the legacy value (already parsed
4024    ///   into `self`) and persist it: strip the `connect` key from
4025    ///   config.json and write connect.json (#457 — NOT a full
4026    ///   [`Config::save_to_dir`], which would re-encrypt every OTHER secret
4027    ///   in config.json and rotate its backups as a load-time side effect,
4028    ///   even for a read-only command like `bamboo config get`), logged at
4029    ///   info.
4030    /// - `connect.json` absent & `self.connect` empty: nothing to do.
4031    fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4032        let connect_path = data_dir.join("connect.json");
4033        match std::fs::read_to_string(&connect_path) {
4034            Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4035                Ok(connect) => {
4036                    let legacy_key_present = !connect_config_is_empty(&self.connect);
4037                    if legacy_key_present {
4038                        tracing::warn!(
4039                            "config.json still has a legacy `connect` key alongside \
4040                             connect.json; connect.json takes precedence — dropping the \
4041                             stale key from config.json now"
4042                        );
4043                    }
4044                    self.connect = connect;
4045                    if legacy_key_present {
4046                        strip_legacy_connect_key_from_config_json(data_dir);
4047                    }
4048                }
4049                Err(e) => {
4050                    tracing::error!(
4051                        "Failed to parse {:?} ({}); continuing with an empty (inert) \
4052                         connect config instead of falling back to a legacy config.json copy",
4053                        connect_path,
4054                        e
4055                    );
4056                    quarantine_corrupt_connect(&connect_path);
4057                    self.connect = ConnectConfig::default();
4058                }
4059            },
4060            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4061                if !connect_config_is_empty(&self.connect) {
4062                    tracing::info!(
4063                        "Migrating legacy `connect` config from config.json to a \
4064                         standalone connect.json"
4065                    );
4066                    // Narrow migration write (#457): strip only the `connect` key
4067                    // from config.json and write connect.json directly, instead of
4068                    // routing through a full `save_to_dir` (see doc comment above).
4069                    strip_legacy_connect_key_from_config_json(data_dir);
4070                    if let Err(e) = save_connect_config(&self.connect, data_dir) {
4071                        tracing::error!("Failed to write connect.json during migration: {}", e);
4072                    }
4073                }
4074            }
4075            Err(e) => {
4076                tracing::error!(
4077                    "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4078                    connect_path,
4079                    e
4080                );
4081                self.connect = ConnectConfig::default();
4082            }
4083        }
4084    }
4085
4086    /// Deserialize config JSON and run the in-memory hydration + normalization
4087    /// chain. Shared by the primary load and the backup-recovery path (#37).
4088    fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4089        serde_json::from_str::<Config>(content).map(|mut config| {
4090            config.hydrate_proxy_auth_from_encrypted();
4091            config.hydrate_provider_api_keys_from_encrypted();
4092            config.hydrate_provider_instance_api_keys_from_encrypted();
4093            config.hydrate_mcp_secrets_from_encrypted();
4094            config.hydrate_env_vars_from_encrypted();
4095            config.hydrate_cluster_fabric_from_encrypted();
4096            config.hydrate_broker_token_from_encrypted();
4097            config.hydrate_notifications_from_encrypted();
4098            config.hydrate_connect_platform_tokens_from_encrypted();
4099            config.normalize_tool_settings();
4100            config.normalize_skill_settings();
4101            config
4102        })
4103    }
4104
4105    /// Try to recover from the rotated `config.json.bak[.N]` generations (each a
4106    /// last-known-good written before a save) when the primary `config.json` is
4107    /// corrupt. Walks newest -> oldest and returns the first that parses (paired
4108    /// with its generation index, 0 == `.bak`, for [`ConfigRecoverySource::Backup`]);
4109    /// `None` if every generation is missing or also unparseable. #37 / #135.
4110    fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4111        let config_path = data_dir.join("config.json");
4112        for gen in 0..BAK_GENERATIONS {
4113            let backup = backup_path_for(&config_path, gen);
4114            let Ok(content) = std::fs::read_to_string(&backup) else {
4115                continue;
4116            };
4117            match Self::parse_and_hydrate(&content) {
4118                Ok(config) => {
4119                    tracing::info!("Recovered configuration from {:?}", backup);
4120                    return Some((config, gen));
4121                }
4122                Err(e) => {
4123                    tracing::warn!(
4124                        "Backup {:?} is unparseable ({}); trying an older generation",
4125                        backup,
4126                        e
4127                    );
4128                }
4129            }
4130        }
4131        None
4132    }
4133
4134    /// Largest corrupt-object key count we'll attempt to salvage. The overlay loop
4135    /// is O(keys) full-Config deserializes over a growing object (the `extra`
4136    /// catch-all absorbs unknown keys), i.e. O(n²) on a pathological file; cap it
4137    /// so a junk-key-flooded config.json can't stall a load. A real config has a
4138    /// few dozen top-level keys, so this only ever trips on garbage.
4139    const SALVAGE_MAX_KEYS: usize = 512;
4140
4141    /// Best-effort PARTIAL salvage of a corrupt `config.json` (#135): parse it as a
4142    /// generic JSON object and overlay each top-level field onto the richest
4143    /// known-good baseline — the last-known-good `config.json.bak` if present, else
4144    /// a fresh default — keeping only the fields that still yield a valid [`Config`].
4145    /// A single bad field (wrong type, malformed section, …) then keeps the
4146    /// baseline's value instead of discarding ALL the user's other settings.
4147    ///
4148    /// Overlaying onto `.bak` (rather than defaults) means the result is the
4149    /// best-of-both: the backup's complete recent-good state PLUS the corrupt
4150    /// file's still-valid newer edits on top — so salvage is never worse than the
4151    /// plain `.bak` fallback, removing the "sparse salvage defeats a rich backup"
4152    /// hazard. Tried BEFORE the bare `.bak` fallback.
4153    ///
4154    /// Returns the hydrated salvaged config, or `None` when the corrupt file isn't
4155    /// even a JSON object (nothing field-wise to salvage) so the caller falls
4156    /// through to `.bak` / defaults.
4157    ///
4158    /// NOTE: the per-field overlay guarantees a VALID `Config`, not a *maximal* or
4159    /// attribution-perfect one. Deterministic alphabetical key order (serde_json is
4160    /// BTreeMap-backed, no `preserve_order`) means a rename/alias pair like
4161    /// `mcp`/`mcpServers` can drop the second-seen even if it'd be valid alone — the
4162    /// outcome is still a valid config, just not necessarily the richest possible.
4163    ///
4164    /// Returns the hydrated salvaged config paired with the top-level keys that
4165    /// were actually recovered from the corrupt document (used to populate
4166    /// [`ConfigRecoverySource::Salvaged`]).
4167    fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4168        // Must at least be a JSON object; otherwise there's nothing field-wise to
4169        // salvage (a truncated/garbage file just falls through to .bak/defaults).
4170        let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4171        let corrupt_obj = corrupt.as_object()?;
4172        if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4173            tracing::warn!(
4174                "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4175                corrupt_obj.len(),
4176                Self::SALVAGE_MAX_KEYS
4177            );
4178            return None;
4179        }
4180
4181        // Overlay onto the richest known-good baseline: the last-known-good backup
4182        // if it parses, else a fresh default. This makes salvage >= the plain .bak
4183        // fallback in every case.
4184        let mut base = Self::load_backup(data_dir)
4185            .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4186            .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4187        let base_obj = base.as_object_mut()?;
4188
4189        let mut salvaged: Vec<String> = Vec::new();
4190        for (key, value) in corrupt_obj {
4191            let previous = base_obj.insert(key.clone(), value.clone());
4192            // Keep the field iff the WHOLE config still deserializes with it
4193            // overlaid — base is valid before each step, so a failure isolates THIS
4194            // field as the corrupt one (and inter-field constraints are respected).
4195            if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4196                salvaged.push(key.clone());
4197            } else {
4198                match previous {
4199                    Some(prev) => {
4200                        base_obj.insert(key.clone(), prev);
4201                    }
4202                    None => {
4203                        base_obj.remove(key);
4204                    }
4205                }
4206            }
4207        }
4208
4209        tracing::warn!(
4210            "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4211             last-known-good/default value",
4212            salvaged.len(),
4213            salvaged.join(", ")
4214        );
4215
4216        // Re-serialize the rebuilt (all-valid) object and run it back through the
4217        // normal parse+hydrate path so secret-decryption / normalization match a
4218        // clean load exactly.
4219        let rebuilt = serde_json::to_string(&base).ok()?;
4220        Self::parse_and_hydrate(&rebuilt)
4221            .ok()
4222            .map(|config| (config, salvaged))
4223    }
4224
4225    /// Get the effective default model for the currently active provider.
4226    ///
4227    /// When `features.provider_model_ref` is enabled, reads from `defaults.chat`
4228    /// before falling back to legacy provider-specific config.
4229    ///
4230    /// Note: for most providers this is a required config value (returns None when absent).
4231    /// Copilot has a built-in fallback when no model is configured.
4232    pub fn get_model(&self) -> Option<String> {
4233        if self.features.provider_model_ref {
4234            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4235                return Some(model_ref.model.clone());
4236            }
4237        }
4238        let provider = self.effective_default_provider();
4239        if let Some(instance) = self.provider_instances.get(provider) {
4240            return instance
4241                .model
4242                .clone()
4243                .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4244        }
4245        match provider {
4246            "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4247            "anthropic" => self
4248                .providers
4249                .anthropic
4250                .as_ref()
4251                .and_then(|c| c.model.clone()),
4252            "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4253            "copilot" => Some(
4254                self.providers
4255                    .copilot
4256                    .as_ref()
4257                    .and_then(|c| c.model.clone())
4258                    .unwrap_or_else(|| "gpt-4o".to_string()),
4259            ),
4260            _ => None,
4261        }
4262    }
4263
4264    /// Get the fast/cheap model for the currently active provider.
4265    ///
4266    /// When `features.provider_model_ref` is enabled, reads from `defaults.fast`
4267    /// before falling back to legacy provider-specific config.
4268    ///
4269    /// Used for lightweight tasks like title generation and summarization.
4270    /// Falls back to `get_model()` when no fast_model is configured.
4271    pub fn get_fast_model(&self) -> Option<String> {
4272        if self.features.provider_model_ref {
4273            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4274                return Some(model_ref.model.clone());
4275            }
4276        }
4277        let provider = self.effective_default_provider();
4278        let fast = if let Some(instance) = self.provider_instances.get(provider) {
4279            instance.fast_model.clone()
4280        } else {
4281            match provider {
4282                "openai" => self
4283                    .providers
4284                    .openai
4285                    .as_ref()
4286                    .and_then(|c| c.fast_model.clone()),
4287                "anthropic" => self
4288                    .providers
4289                    .anthropic
4290                    .as_ref()
4291                    .and_then(|c| c.fast_model.clone()),
4292                "gemini" => self
4293                    .providers
4294                    .gemini
4295                    .as_ref()
4296                    .and_then(|c| c.fast_model.clone()),
4297                "copilot" => self
4298                    .providers
4299                    .copilot
4300                    .as_ref()
4301                    .and_then(|c| c.fast_model.clone()),
4302                _ => None,
4303            }
4304        };
4305        fast.or_else(|| self.get_model())
4306    }
4307
4308    /// Get the configured task summarization model.
4309    ///
4310    /// When `features.provider_model_ref` is enabled, reads from
4311    /// `defaults.task_summary` before falling back through
4312    /// `defaults.memory_background` → `defaults.fast` → `defaults.chat`.
4313    ///
4314    /// This is used for conversation/task summarization and context compression.
4315    pub fn get_task_summary_model(&self) -> Option<String> {
4316        if self.features.provider_model_ref {
4317            if let Some(model_ref) = self
4318                .defaults
4319                .as_ref()
4320                .and_then(|d| d.task_summary.as_ref())
4321                .or_else(|| {
4322                    self.defaults
4323                        .as_ref()
4324                        .and_then(|d| d.memory_background.as_ref())
4325                })
4326                .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4327                .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4328            {
4329                return Some(model_ref.model.clone());
4330            }
4331        }
4332
4333        self.get_memory_background_model()
4334            .or_else(|| self.get_model())
4335    }
4336
4337    /// Get the configured memory/background summarization model.
4338    ///
4339    /// When `features.provider_model_ref` is enabled, reads from
4340    /// `defaults.memory_background` before falling back to legacy config.
4341    ///
4342    /// Falls back to the provider fast model when no background model is
4343    /// configured or resolves to an empty string.
4344    ///
4345    /// IMPORTANT: this intentionally does **not** fall back to the main
4346    /// interaction model. Memory compaction / reflection should be skipped or
4347    /// fail loudly when no background/fast model is configured.
4348    pub fn get_memory_background_model(&self) -> Option<String> {
4349        if self.features.provider_model_ref {
4350            if let Some(model_ref) = self
4351                .defaults
4352                .as_ref()
4353                .and_then(|d| d.memory_background.as_ref())
4354            {
4355                return Some(model_ref.model.clone());
4356            }
4357            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4358                return Some(model_ref.model.clone());
4359            }
4360        }
4361        let configured = self
4362            .memory
4363            .as_ref()
4364            .and_then(|memory| memory.background_model.as_ref())
4365            .map(|value| value.trim())
4366            .filter(|value| !value.is_empty())
4367            .map(ToString::to_string);
4368        configured.or_else(|| {
4369            let provider = self.effective_default_provider();
4370            if let Some(instance) = self.provider_instances.get(provider) {
4371                return instance.fast_model.clone();
4372            }
4373            match provider {
4374                "openai" => self
4375                    .providers
4376                    .openai
4377                    .as_ref()
4378                    .and_then(|c| c.fast_model.clone()),
4379                "anthropic" => self
4380                    .providers
4381                    .anthropic
4382                    .as_ref()
4383                    .and_then(|c| c.fast_model.clone()),
4384                "gemini" => self
4385                    .providers
4386                    .gemini
4387                    .as_ref()
4388                    .and_then(|c| c.fast_model.clone()),
4389                "copilot" => self
4390                    .providers
4391                    .copilot
4392                    .as_ref()
4393                    .and_then(|c| c.fast_model.clone()),
4394                _ => None,
4395            }
4396        })
4397    }
4398
4399    /// Resolve the configured default work area path when present.
4400    ///
4401    /// This validates that the configured directory exists, but intentionally
4402    /// returns the stable expanded path rather than the platform-specific
4403    /// canonicalized path. On macOS, `canonicalize()` may rewrite `/var/...`
4404    /// to `/private/var/...`, which is correct at the filesystem layer but
4405    /// undesirable as a user-facing/config-derived workspace path.
4406    pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4407        let raw = self
4408            .default_work_area
4409            .as_ref()
4410            .and_then(|config| config.path.as_ref())
4411            .map(|value| value.trim())
4412            .filter(|value| !value.is_empty())?;
4413
4414        let candidate = expand_user_path(raw);
4415        if candidate.is_absolute() {
4416            let canonical = std::fs::canonicalize(&candidate).ok();
4417            return canonical
4418                .as_ref()
4419                .filter(|path| path.is_dir())
4420                .map(|_| candidate.clone())
4421                .or_else(|| candidate.is_dir().then_some(candidate));
4422        }
4423
4424        let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4425        let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4426        canonical
4427            .as_ref()
4428            .filter(|path| path.is_dir())
4429            .map(|_| from_bamboo_dir.clone())
4430            .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4431            .or_else(|| candidate.is_dir().then_some(candidate))
4432    }
4433
4434    /// Get the vision-capable model for the currently active provider.
4435    ///
4436    /// Used for image understanding tasks.
4437    /// Falls back to `get_model()` when no vision_model is configured.
4438    pub fn get_vision_model(&self) -> Option<String> {
4439        let provider = self.effective_default_provider();
4440        let vision = if let Some(instance) = self.provider_instances.get(provider) {
4441            instance.vision_model.clone()
4442        } else {
4443            match provider {
4444                "openai" => self
4445                    .providers
4446                    .openai
4447                    .as_ref()
4448                    .and_then(|c| c.vision_model.clone()),
4449                "anthropic" => self
4450                    .providers
4451                    .anthropic
4452                    .as_ref()
4453                    .and_then(|c| c.vision_model.clone()),
4454                "gemini" => self
4455                    .providers
4456                    .gemini
4457                    .as_ref()
4458                    .and_then(|c| c.vision_model.clone()),
4459                "copilot" => self
4460                    .providers
4461                    .copilot
4462                    .as_ref()
4463                    .and_then(|c| c.vision_model.clone()),
4464                _ => None,
4465            }
4466        };
4467        vision.or_else(|| self.get_model())
4468    }
4469
4470    /// Get the default reasoning effort for the currently active provider.
4471    pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4472        self.reasoning_effort_for_key(self.effective_default_provider())
4473    }
4474
4475    /// Resolve the configured default reasoning effort for a provider routing key.
4476    ///
4477    /// The key may be a multi-instance provider id (for example `"copilot-work"`)
4478    /// or a legacy provider type (for example `"openai"`). In multi-instance mode
4479    /// the per-instance `reasoning_effort` lives under `provider_instances[<id>]`,
4480    /// so we resolve instance ids there first; otherwise we fall back to the
4481    /// legacy per-provider config. Both the execute path
4482    /// ([`crate`]'s `get_reasoning_effort_for_provider`) and the session-create
4483    /// path ([`Self::get_reasoning_effort`]) delegate here so the two cannot drift.
4484    pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4485        let trimmed = key.trim();
4486        if trimmed.is_empty() {
4487            return None;
4488        }
4489
4490        // Multi-instance mode: the routing key is an instance id.
4491        if let Some(instance) = self.provider_instances.get(trimmed) {
4492            return instance.reasoning_effort;
4493        }
4494
4495        // Legacy mode: the routing key is a provider type.
4496        match trimmed {
4497            "openai" => self
4498                .providers
4499                .openai
4500                .as_ref()
4501                .and_then(|c| c.reasoning_effort),
4502            "anthropic" => self
4503                .providers
4504                .anthropic
4505                .as_ref()
4506                .and_then(|c| c.reasoning_effort),
4507            "gemini" => self
4508                .providers
4509                .gemini
4510                .as_ref()
4511                .and_then(|c| c.reasoning_effort),
4512            "copilot" => self
4513                .providers
4514                .copilot
4515                .as_ref()
4516                .and_then(|c| c.reasoning_effort),
4517            "bodhi" => self
4518                .providers
4519                .bodhi
4520                .as_ref()
4521                .and_then(|c| c.reasoning_effort),
4522            _ => None,
4523        }
4524    }
4525
4526    /// Get exact disabled tool references for catalog-aware resolution.
4527    ///
4528    /// References remain exact here: catalog-aware filtering resolves an exact
4529    /// registered name before applying legacy/builtin alias fallback. Eagerly
4530    /// rewriting `apply_patch` to `Edit`, for example, would make an exact
4531    /// custom `apply_patch` registration indistinguishable from the builtin.
4532    pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4533        self.tools
4534            .disabled
4535            .iter()
4536            .map(|name| name.trim())
4537            .filter(|name| !name.is_empty())
4538            .map(str::to_string)
4539            .collect()
4540    }
4541
4542    /// Legacy normalized-name facade retained for source compatibility.
4543    ///
4544    /// New execution/catalog code must use [`Self::disabled_tool_references`]
4545    /// so an exact registered alias can be resolved before fallback.
4546    pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4547        self.disabled_tool_references()
4548            .into_iter()
4549            .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4550            .collect()
4551    }
4552
4553    /// Normalize tool settings (trim / dedupe / sort).
4554    pub fn normalize_tool_settings(&mut self) {
4555        self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4556    }
4557
4558    /// Get normalized disabled skill IDs.
4559    pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4560        self.skills
4561            .disabled
4562            .iter()
4563            .map(|id| id.trim())
4564            .filter(|id| !id.is_empty())
4565            .map(|id| id.to_string())
4566            .collect()
4567    }
4568
4569    /// Normalize skill settings (trim / dedupe / sort).
4570    pub fn normalize_skill_settings(&mut self) {
4571        self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4572    }
4573
4574    /// Normalize `plugin_trust.trusted_hosts` entries (trim / lowercase / drop
4575    /// empties) so a hand-edited `config.json` doesn't silently accumulate
4576    /// mixed-case or whitespace-padded entries. [`is_host_trusted`] itself
4577    /// already matches case-insensitively regardless of how an entry is
4578    /// stored, so this is defense in depth / a canonical on-disk form, not
4579    /// the source of the security fix — that's the host/path-component
4580    /// matching in [`is_host_trusted`] itself.
4581    pub fn normalize_plugin_trust_settings(&mut self) {
4582        self.plugin_trust.trusted_hosts = self
4583            .plugin_trust
4584            .trusted_hosts
4585            .iter()
4586            .map(|entry| entry.trim().to_ascii_lowercase())
4587            .filter(|entry| !entry.is_empty())
4588            .collect();
4589    }
4590
4591    /// Return the effective default provider key.
4592    ///
4593    /// Prefers `default_provider_instance` when set; falls back to the
4594    /// legacy `provider` string.
4595    pub fn effective_default_provider(&self) -> &str {
4596        self.default_provider_instance
4597            .as_deref()
4598            .unwrap_or(&self.provider)
4599    }
4600
4601    /// Whether provider instances are configured (new multi-instance path).
4602    pub fn has_provider_instances(&self) -> bool {
4603        !self.provider_instances.is_empty()
4604    }
4605
4606    /// Build a flat map of all env vars with non-empty values (for process injection).
4607    pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4608        self.env_vars
4609            .iter()
4610            .filter(|e| !e.value.trim().is_empty())
4611            .map(|e| (e.name.clone(), e.value.clone()))
4612            .collect()
4613    }
4614
4615    fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4616        self.env_vars
4617            .iter()
4618            .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4619            .map(|entry| PromptSafeEnvVarEntry {
4620                name: entry.name.clone(),
4621                secret: entry.secret,
4622                description: entry
4623                    .description
4624                    .as_ref()
4625                    .map(|value| value.trim().to_string())
4626                    .filter(|value| !value.is_empty()),
4627            })
4628            .collect()
4629    }
4630
4631    /// Update the global env vars cache (called on config load / reload).
4632    pub fn publish_env_vars(&self) {
4633        let map = self.env_vars_as_map();
4634
4635        #[cfg(any(test, feature = "test-utils"))]
4636        if crate::test_support::env_vars_cache_override_is_active() {
4637            crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4638            return;
4639        }
4640
4641        let mut env_guard = env_vars_cache().write().recover_poison();
4642        *env_guard = map;
4643
4644        let prompt_safe = self.prompt_safe_env_vars();
4645        let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4646        *prompt_guard = prompt_safe;
4647    }
4648
4649    /// Read the current env vars snapshot (called by Bash tool at process spawn time).
4650    pub fn current_env_vars() -> HashMap<String, String> {
4651        #[cfg(any(test, feature = "test-utils"))]
4652        if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4653            return env_vars;
4654        }
4655
4656        env_vars_cache().read().recover_poison().clone()
4657    }
4658
4659    /// Read the current prompt-safe env var snapshot (names + metadata only; no secret values).
4660    pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4661        #[cfg(any(test, feature = "test-utils"))]
4662        if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4663            return env_vars;
4664        }
4665
4666        prompt_safe_env_vars_cache().read().recover_poison().clone()
4667    }
4668
4669    /// Create a default configuration without loading from file
4670    fn create_default() -> Self {
4671        Self::from_parts(
4672            ConfigValues {
4673                http_proxy: String::new(),
4674                https_proxy: String::new(),
4675                proxy_auth: None,
4676                proxy_auth_encrypted: None,
4677                proxy_auth_credential_ref: None,
4678                headless_auth: false,
4679                run_budget: RunBudgetConfig::default(),
4680                stream_timeout: StreamTimeoutConfig::default(),
4681                context_management: ContextManagementConfig::default(),
4682                cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4683                provider: default_provider(),
4684                provider_instances: HashMap::new(),
4685                default_provider_instance: None,
4686                server: ServerConfig::default(),
4687                keyword_masking: KeywordMaskingConfig::default(),
4688                anthropic_model_mapping: AnthropicModelMapping::default(),
4689                gemini_model_mapping: GeminiModelMapping::default(),
4690                hooks: HooksConfig::default(),
4691                lifecycle_hooks: LifecycleHooksConfig::default(),
4692                tools: ToolsConfig::default(),
4693                skills: SkillsConfig::default(),
4694                env_vars: Vec::new(),
4695                default_work_area: None,
4696                access_control: None,
4697                features: FeatureFlags::default(),
4698                defaults: None,
4699                mcp: bamboo_domain::mcp_config::McpConfig::default(),
4700                notifications: NotificationsConfig::default(),
4701                connect: ConnectConfig::default(),
4702                plugin_trust: PluginTrustConfig::default(),
4703                extra: BTreeMap::new(),
4704            },
4705            None,
4706            SubagentsConfig::default(),
4707            ProviderConfigs::default(),
4708        )
4709    }
4710
4711    /// Get the full server address (bind:port)
4712    pub fn server_addr(&self) -> String {
4713        format!("{}:{}", self.server.bind, self.server.port)
4714    }
4715
4716    /// Save configuration to disk
4717    pub fn save(&self) -> Result<()> {
4718        self.save_to_dir(default_data_dir())
4719    }
4720
4721    /// Persist only the memory module, leaving every other config file untouched.
4722    pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4723        self.memory.save_sync(data_dir)
4724    }
4725
4726    /// Persist only the sub-agent module, leaving every other config file untouched.
4727    pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4728        self.subagents.save_sync(data_dir)
4729    }
4730
4731    /// Persist only provider configuration. Provider plaintext keys are first
4732    /// refreshed into their encrypted at-rest representation.
4733    pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4734        let mut config = self.clone();
4735        config.clear_legacy_provider_aliases_for_instance_mode();
4736        config.refresh_provider_api_keys_encrypted()?;
4737        config.providers.save_sync(data_dir)
4738    }
4739
4740    /// Build the metadata-only documents used by a provider credential
4741    /// transaction. Nothing is written here: the migration journal owns the
4742    /// durable commit and publishes both documents together with credentials.
4743    pub(crate) fn prepare_provider_transaction_documents(
4744        &self,
4745        provider_document: &[u8],
4746    ) -> Result<(Vec<u8>, Vec<u8>)> {
4747        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4748            anyhow::bail!(
4749                "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
4750                status.source
4751            );
4752        }
4753
4754        let mut to_save = self.clone();
4755        to_save.clear_legacy_provider_aliases_for_instance_mode();
4756        to_save.extra.remove("data_dir");
4757        to_save.extra.remove("model");
4758        to_save.refresh_encrypted_secrets()?;
4759        to_save.ensure_provider_instance_credentials_isolated()?;
4760        to_save.sanitize_mcp_credential_refs_for_disk();
4761        to_save.sanitize_env_vars_for_disk();
4762        to_save.sanitize_notifications_for_disk();
4763        to_save.sanitize_cluster_fabric_for_disk();
4764        to_save.assign_connect_platform_ids();
4765        to_save.normalize_tool_settings();
4766        to_save.normalize_skill_settings();
4767
4768        let mut root = durable_root_value(to_save.values.clone())
4769            .context("Failed to serialize root config DTO to JSON")?;
4770        if let Some(object) = root.as_object_mut() {
4771            object.remove("connect");
4772        }
4773        let root = serde_json::to_vec_pretty(&root)?;
4774
4775        let mut providers = to_save.providers.0.clone();
4776        macro_rules! sanitize_provider {
4777            ($field:ident) => {
4778                if let Some(provider) = providers.$field.as_mut() {
4779                    if !provider.api_key.trim().is_empty()
4780                        && !provider.api_key_from_env
4781                        && provider.credential_ref.is_none()
4782                    {
4783                        anyhow::bail!(
4784                            "provider secret requires credential transaction before persistence"
4785                        );
4786                    }
4787                    provider.api_key_encrypted = None;
4788                }
4789            };
4790        }
4791        sanitize_provider!(openai);
4792        sanitize_provider!(anthropic);
4793        sanitize_provider!(gemini);
4794        if let Some(provider) = providers.bodhi.as_mut() {
4795            if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
4796                anyhow::bail!("provider secret requires credential transaction before persistence");
4797            }
4798            provider.api_key_encrypted = None;
4799        }
4800
4801        let existing_provider_value = if provider_document.is_empty() {
4802            None
4803        } else {
4804            Some(
4805                serde_json::from_slice::<Value>(provider_document)
4806                    .context("provider metadata document is invalid")?,
4807            )
4808        };
4809        let provider_value = match existing_provider_value {
4810            Some(Value::Object(mut envelope))
4811                if envelope.contains_key("schema_version")
4812                    || envelope.contains_key("revision")
4813                    || envelope.contains_key("data") =>
4814            {
4815                let revision = envelope
4816                    .get("revision")
4817                    .and_then(Value::as_u64)
4818                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4819                let schema_version = envelope
4820                    .get("schema_version")
4821                    .and_then(Value::as_u64)
4822                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4823                if schema_version != 1 || !envelope.contains_key("data") {
4824                    anyhow::bail!("provider revision envelope is unsupported");
4825                }
4826                let revision = revision
4827                    .checked_add(1)
4828                    .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
4829                envelope.insert("revision".into(), Value::from(revision));
4830                envelope.insert("data".into(), serde_json::to_value(providers)?);
4831                Value::Object(envelope)
4832            }
4833            Some(_) | None => serde_json::to_value(providers)?,
4834        };
4835        Ok((root, serde_json::to_vec_pretty(&provider_value)?))
4836    }
4837
4838    /// The pending config-corruption recovery, if `config.json` failed to
4839    /// parse on load and the recovery hasn't been confirmed yet. `None` on
4840    /// every clean load. #153.
4841    pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
4842        self.recovery_status.as_ref()
4843    }
4844
4845    /// Confirm a pending recovery, allowing the next [`Config::save`] /
4846    /// [`Config::save_to_dir`] to overwrite the quarantined-corrupt
4847    /// `config.json` with this recovered state. No-op if there's no pending
4848    /// recovery. Prefer [`Config::confirm_recovery_and_save_to_dir`], which
4849    /// also persists and clears the flag in one step. #153.
4850    pub fn confirm_recovery(&mut self) {
4851        if let Some(status) = self.recovery_status.as_mut() {
4852            status.confirmed = true;
4853        }
4854    }
4855
4856    /// Confirm a pending recovery AND persist it in one step: marks it
4857    /// confirmed (satisfying the [`Config::save_to_dir`] guard), writes the
4858    /// recovered state to `config.json`, then clears `recovery_status`
4859    /// entirely — once this succeeds the config is no longer "pending
4860    /// confirmation", it's just the normal on-disk config. Errors (and
4861    /// leaves `recovery_status` untouched) if there's nothing pending, or if
4862    /// the save itself fails. #153.
4863    pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
4864        if self.recovery_status.is_none() {
4865            anyhow::bail!("No pending config-corruption recovery to confirm");
4866        }
4867        self.confirm_recovery();
4868        self.save_to_dir(data_dir)?;
4869        self.recovery_status = None;
4870        Ok(())
4871    }
4872
4873    /// Assign a stable [`ConnectPlatformConfig::id`] to every `connect.platforms`
4874    /// entry that doesn't already have one (#496).
4875    ///
4876    /// Migration-on-write: [`Config::save_to_dir`] always calls this on its
4877    /// internal save-copy before persisting, so every path that writes
4878    /// `connect.json` gets ids backfilled. Callers that mutate the *live*
4879    /// in-memory config as part of a save (e.g. the server's settings-PATCH
4880    /// handler) should also call this directly on that in-memory value
4881    /// before responding, so a client that echoes the response straight
4882    /// back round-trips the id immediately rather than only after the next
4883    /// reload/restart. Never called from load — a config that's never saved
4884    /// again (e.g. one sitting in an unconfirmed-recovery state, see #493)
4885    /// is never rewritten just to backfill ids. An entry that already has
4886    /// an id keeps it unchanged; ids are never reassigned or deduplicated
4887    /// once set.
4888    pub fn assign_connect_platform_ids(&mut self) {
4889        for platform in &mut self.connect.platforms {
4890            if platform.id.is_none() {
4891                platform.id = Some(uuid::Uuid::new_v4().to_string());
4892            }
4893        }
4894    }
4895
4896    /// Save configuration to disk under the provided data directory.
4897    ///
4898    /// Root configuration is stored as `{data_dir}/config.json`; extracted
4899    /// memory, sub-agent, and provider modules are stored in sibling sidecars.
4900    ///
4901    /// Refuses to write when this config carries an unconfirmed
4902    /// [`ConfigRecoveryStatus`] (#153) — i.e. it was recovered from a corrupt
4903    /// `config.json` and the recovery hasn't been confirmed — so a corrupt
4904    /// original a user might want to hand-fix is never silently clobbered by
4905    /// an auto-persisted recovery. Call [`Config::confirm_recovery`] (or
4906    /// [`Config::confirm_recovery_and_save_to_dir`]) first.
4907    pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
4908        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4909            anyhow::bail!(
4910                "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
4911                 has not been confirmed; the corrupt original is preserved at {:?}. Call \
4912                 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
4913                status.source,
4914                status.quarantine_path,
4915            );
4916        }
4917        if self.proxy_auth_credential_ref.is_none()
4918            && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
4919        {
4920            anyhow::bail!(
4921                "proxy auth requires the isolated credential transaction before persistence"
4922            );
4923        }
4924        if self.env_vars.iter().any(|entry| {
4925            entry.secret
4926                && entry.credential_ref.is_none()
4927                && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
4928        }) {
4929            anyhow::bail!(
4930                "secret env vars require the isolated credential transaction before persistence"
4931            );
4932        }
4933        if [
4934            (
4935                &self.notifications.ntfy.token,
4936                &self.notifications.ntfy.token_encrypted,
4937                &self.notifications.ntfy.credential_ref,
4938            ),
4939            (
4940                &self.notifications.bark.device_key,
4941                &self.notifications.bark.device_key_encrypted,
4942                &self.notifications.bark.credential_ref,
4943            ),
4944        ]
4945        .iter()
4946        .any(|(plaintext, ciphertext, reference)| {
4947            reference.is_none()
4948                && (plaintext
4949                    .as_deref()
4950                    .is_some_and(|value| !value.trim().is_empty())
4951                    || ciphertext.is_some())
4952        }) {
4953            anyhow::bail!(
4954                "notification secrets require the isolated credential transaction before persistence"
4955            );
4956        }
4957
4958        if crate::modular_authority_boundary_present(&data_dir)
4959            .context("Failed to inspect modular configuration authority boundary")?
4960        {
4961            crate::ConfigFacade::open_or_migrate(&data_dir)
4962                .context("Failed to recover modular configuration before persistence")?;
4963            crate::persist_facade_effective_config(&data_dir, self)
4964                .context("Failed to persist modular configuration sections")?;
4965            return Ok(());
4966        }
4967
4968        if crate::section_layout_is_active(&data_dir)
4969            .context("Failed to inspect modular configuration layout")?
4970        {
4971            crate::persist_facade_effective_config(&data_dir, self)
4972                .context("Failed to persist modular configuration sections")?;
4973            return Ok(());
4974        }
4975
4976        let path = data_dir.join("config.json");
4977
4978        if let Some(parent) = path.parent() {
4979            std::fs::create_dir_all(parent)
4980                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
4981        }
4982
4983        let mut to_save = self.clone();
4984        to_save.clear_legacy_provider_aliases_for_instance_mode();
4985        // Never persist `data_dir` into config.json (data dir is runtime-derived).
4986        to_save.extra.remove("data_dir");
4987        // Root-level `model` is deprecated; do not persist it.
4988        to_save.extra.remove("model");
4989        // `subagents.broker` is `#[serde(skip)]` (runtime-only, lives in its own
4990        // broker.json / embedded in-process) — nothing to encrypt or persist here.
4991        to_save.refresh_encrypted_secrets()?;
4992        to_save.ensure_provider_instance_credentials_isolated()?;
4993        to_save.sanitize_mcp_credential_refs_for_disk();
4994        to_save.sanitize_env_vars_for_disk();
4995        to_save.sanitize_notifications_for_disk();
4996        to_save.sanitize_cluster_fabric_for_disk();
4997        to_save.assign_connect_platform_ids();
4998        to_save.normalize_tool_settings();
4999        to_save.normalize_skill_settings();
5000
5001        // Split `connect` (#455) out of the config.json document: bamboo-connect
5002        // platform-bridge credentials (bot tokens, allowlists) get their own
5003        // sibling file, connect.json (written below), instead of living in
5004        // config.json — different sensitivity/lifecycle. The `connect` FIELD on
5005        // `Config` keeps its normal serde shape unchanged (still required by the
5006        // settings API / `preserve_masked_connect_secrets`, which operate on the
5007        // in-memory struct) — only the serialized DOCUMENT that becomes
5008        // config.json's bytes has the key stripped, and that's done on the
5009        // `serde_json::Value` here, not via `#[serde(skip)]` on the field.
5010        let mut config_value = durable_root_value(to_save.values.clone())
5011            .context("Failed to serialize root config DTO to JSON")?;
5012        if let Some(obj) = config_value.as_object_mut() {
5013            obj.remove("connect");
5014        }
5015        let content = serde_json::to_string_pretty(&config_value)
5016            .context("Failed to serialize config to JSON")?;
5017
5018        // Persist extracted modules before stripping their legacy inline
5019        // representation from config.json. If the process crashes or the root
5020        // rewrite fails during the first migration, the next load can still use
5021        // either the new sidecars or the untouched inline values. Do this before
5022        // rotating root backups so a sidecar error cannot consume backup history
5023        // for a root document that was never rewritten.
5024        to_save.memory.save_sync(&data_dir)?;
5025        to_save.subagents.save_sync(&data_dir)?;
5026        to_save.providers.save_sync(&data_dir)?;
5027
5028        // Back up the current on-disk config (last-known-good) before overwriting,
5029        // so corruption (a bad/partial write, external edit, disk issue) stays
5030        // recoverable via config.json.bak on the next load. Best-effort. Only
5031        // refresh the backup from a PARSEABLE config.json — otherwise a save right
5032        // after an in-memory recovery (where the on-disk config.json is still the
5033        // corrupt original) would clobber the good .bak with garbage. #37.
5034        if path.exists()
5035            && std::fs::read_to_string(&path)
5036                .ok()
5037                .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5038        {
5039            // Rotate the older generations down (.bak -> .bak.1 -> .bak.2 …) so a
5040            // few last-known-good snapshots survive, then snapshot the current
5041            // (parseable) config.json as the freshest .bak. #135.
5042            rotate_backups(&path, BAK_GENERATIONS);
5043            let backup = backup_path_for(&path, 0);
5044            let backup_result = std::fs::read(&path).and_then(|bytes| {
5045                let mut value: Value = serde_json::from_slice(&bytes)
5046                    .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5047                if sanitize_ref_backed_mcp_json(&mut value) {
5048                    let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5049                        std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5050                    })?;
5051                    write_atomic(&backup, &sanitized)
5052                } else {
5053                    std::fs::copy(&path, &backup).map(|_| ())
5054                }
5055            });
5056            if let Err(e) = backup_result {
5057                tracing::warn!("Failed to back up config.json before save: {}", e);
5058            }
5059            scrub_ref_backed_mcp_from_config_backups(&path);
5060        }
5061
5062        write_atomic(&path, content.as_bytes())
5063            .with_context(|| format!("Failed to write config file: {:?}", path))?;
5064
5065        save_connect_config(&to_save.connect, &data_dir)?;
5066
5067        Ok(())
5068    }
5069}
5070
5071/// Remove credential-ref-backed MCP values from a raw root document without
5072/// otherwise normalizing its compatibility shape. This is used for rotated
5073/// root backups as well as the typed disk DTO so a pre-fix root cannot keep a
5074/// duplicate secret alive for several more saves.
5075fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5076    let Some(object) = root.as_object_mut() else {
5077        return false;
5078    };
5079    let Some(mcp) = (if object.contains_key("mcpServers") {
5080        object.get_mut("mcpServers")
5081    } else {
5082        object.get_mut("mcp")
5083    }) else {
5084        return false;
5085    };
5086    let mut changed = false;
5087    if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5088        for server in servers {
5089            if let Some(object) = server.as_object_mut() {
5090                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5091                {
5092                    changed |= sanitize_ref_backed_mcp_transport(transport);
5093                }
5094            }
5095        }
5096    } else if let Some(servers) = mcp.as_object_mut() {
5097        for server in servers.values_mut() {
5098            if let Some(object) = server.as_object_mut() {
5099                changed |= sanitize_ref_backed_mcp_transport(object);
5100                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5101                {
5102                    changed |= sanitize_ref_backed_mcp_transport(transport);
5103                }
5104            }
5105        }
5106    }
5107    changed
5108}
5109
5110fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5111    let mut changed = false;
5112    let env_names = object
5113        .get("env_credential_refs")
5114        .and_then(Value::as_object)
5115        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5116        .unwrap_or_default();
5117    for name in env_names {
5118        for field in ["env", "env_encrypted"] {
5119            if object
5120                .get_mut(field)
5121                .and_then(Value::as_object_mut)
5122                .and_then(|values| values.remove(&name))
5123                .is_some()
5124            {
5125                changed = true;
5126            }
5127        }
5128    }
5129    let header_names = object
5130        .get("header_credential_refs")
5131        .and_then(Value::as_object)
5132        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5133        .unwrap_or_default();
5134    for name in header_names {
5135        for field in ["headers", "headers_encrypted"] {
5136            if object
5137                .get_mut(field)
5138                .and_then(Value::as_object_mut)
5139                .and_then(|values| values.remove(&name))
5140                .is_some()
5141            {
5142                changed = true;
5143            }
5144        }
5145    }
5146    if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5147        for header in headers {
5148            let Some(header) = header.as_object_mut() else {
5149                continue;
5150            };
5151            if header.get("credential_ref").is_some_and(Value::is_string) {
5152                changed |= header.remove("value").is_some();
5153                changed |= header.remove("value_encrypted").is_some();
5154            }
5155        }
5156    }
5157    changed
5158}
5159
5160fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5161    for generation in 0..BAK_GENERATIONS {
5162        let backup = backup_path_for(path, generation);
5163        let Ok(bytes) = std::fs::read(&backup) else {
5164            continue;
5165        };
5166        let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5167            continue;
5168        };
5169        if sanitize_ref_backed_mcp_json(&mut value) {
5170            if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5171                if let Err(error) = write_atomic(&backup, &sanitized) {
5172                    tracing::warn!(
5173                        "Failed to scrub MCP credentials from {:?}: {}",
5174                        backup,
5175                        error
5176                    );
5177                }
5178            }
5179        }
5180    }
5181}
5182
5183/// Persist `connect` (#455) to its own sibling file, `connect.json`, next to
5184/// config.json — the save-side counterpart of [`Config::merge_connect_config`].
5185///
5186/// Only writes when the config is non-empty OR the file already exists, so a
5187/// fresh/default install with no platforms configured never gets a
5188/// `connect.json` littering its data dir. Before an existing file is
5189/// overwritten, it's copied aside to a single `connect.json.bak` generation
5190/// (best-effort) — connect.json doesn't need config.json's multi-generation
5191/// rotation, one last-known-good snapshot is enough.
5192fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5193    let path = data_dir.join("connect.json");
5194    if connect_config_is_empty(connect) && !path.exists() {
5195        return Ok(());
5196    }
5197
5198    if path.exists() {
5199        let backup = path.with_extension("json.bak");
5200        if let Err(e) = std::fs::copy(&path, &backup) {
5201            tracing::warn!("Failed to back up connect.json before save: {}", e);
5202        }
5203    }
5204
5205    let content = serde_json::to_string_pretty(connect)
5206        .context("Failed to serialize connect config to JSON")?;
5207    write_atomic(&path, content.as_bytes())
5208        .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5209    Ok(())
5210}
5211
5212/// Remove the legacy inline `connect` key from `config.json` on disk, if
5213/// present — the narrow, load-side counterpart of the full-document rewrite
5214/// [`Config::save_to_dir`] would otherwise perform just to drop one stale
5215/// key. Used by [`Config::merge_connect_config`] both when adopting a
5216/// pure-legacy `connect` key (migration) and when a stale legacy key lingers
5217/// alongside an authoritative connect.json. #457.
5218///
5219/// Operates on the raw `serde_json::Value` read straight from disk — NOT on
5220/// the typed `Config` — so it touches nothing but the one key: no other
5221/// secret gets re-encrypted, and no `config.json.bak` generation gets
5222/// rotated, as a side effect of a load.
5223///
5224/// Best-effort: read/parse/write failures are logged, not propagated — this
5225/// runs as a side effect of `Config::new()` / load, which has no `Result` to
5226/// surface it through. A failure here just leaves the stale key in place
5227/// until the next natural save; connect.json (written separately) is already
5228/// authoritative in memory either way.
5229fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5230    let config_path = data_dir.join("config.json");
5231    let content = match std::fs::read_to_string(&config_path) {
5232        Ok(content) => content,
5233        Err(e) => {
5234            tracing::error!(
5235                "Failed to read config.json to strip legacy `connect` key: {}",
5236                e
5237            );
5238            return;
5239        }
5240    };
5241    let mut value: serde_json::Value = match serde_json::from_str(&content) {
5242        Ok(value) => value,
5243        Err(e) => {
5244            tracing::error!(
5245                "Failed to parse config.json to strip legacy `connect` key: {}",
5246                e
5247            );
5248            return;
5249        }
5250    };
5251    let Some(obj) = value.as_object_mut() else {
5252        return;
5253    };
5254    if obj.remove("connect").is_none() {
5255        // Nothing to strip (e.g. raced with a concurrent save that already
5256        // dropped it) — avoid an unnecessary rewrite.
5257        return;
5258    }
5259    let rewritten = match serde_json::to_string_pretty(&value) {
5260        Ok(rewritten) => rewritten,
5261        Err(e) => {
5262            tracing::error!(
5263                "Failed to serialize config.json after stripping legacy `connect` key: {}",
5264                e
5265            );
5266            return;
5267        }
5268    };
5269    if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5270        tracing::error!(
5271            "Failed to write config.json after stripping legacy `connect` key: {}",
5272            e
5273        );
5274    }
5275}
5276
5277/// Sweep the rotated `config.json.bak[.N]` generations for a legacy embedded
5278/// `connect` sub-tree that predates the #455 connect.json split, and strip it
5279/// in place. #468 (follow-up to #457).
5280///
5281/// `strip_legacy_connect_key_from_config_json` only ever rewrites the CURRENT
5282/// `config.json` — it never reaches into `.bak` generations, and the normal
5283/// backup-rotation path (see [`rotate_backups`]) only overwrites a `.bak[.N]`
5284/// slot as a side effect of a fresh SAVE. An instance that upgraded from a
5285/// pre-#455 build but rarely (or never) triggers a config save can therefore
5286/// carry the legacy, encrypted `connect` sub-tree — including bot tokens, an
5287/// immediately-usable remote-control credential — in an old backup generation
5288/// indefinitely, even after its live config.json has long since been
5289/// migrated.
5290///
5291/// Deliberately surgical, mirroring the `.bak` files' role as the user's
5292/// recovery net (#493's "backups are a low-sensitivity snapshot, don't fuss
5293/// with them" posture):
5294/// - a generation that doesn't exist, or that fails to parse as JSON, is
5295///   SKIPPED — logged, never deleted, never guessed at. Corrupt/foreign
5296///   content in a `.bak` slot is left exactly as found for hand inspection.
5297/// - a generation that parses but carries no `connect` key (the overwhelming
5298///   majority, especially on any instance that predates this fix by more
5299///   than `BAK_GENERATIONS` saves) is left COMPLETELY untouched — not even a
5300///   byte-identical rewrite — so its mtime and on-disk bytes survive.
5301/// - only a generation that actually parses AND carries the legacy key gets
5302///   rewritten, via the same key-removal-on-the-raw-`Value` + `write_atomic`
5303///   approach as `strip_legacy_connect_key_from_config_json`, so every other
5304///   byte of that snapshot (all other settings, formatting aside) survives.
5305///
5306/// Runs unconditionally on every load (not gated on the CURRENT config.json
5307/// still carrying the legacy key) specifically to catch already-migrated
5308/// installs whose backups predate this fix. Cheap: at most `BAK_GENERATIONS`
5309/// small file reads, and a genuine no-op (zero writes) once every generation
5310/// has been swept once. Best-effort like its sibling: failures are logged,
5311/// not propagated, since this runs as a side effect of `Config::new()` /
5312/// load, which has no `Result` to surface it through.
5313fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5314    let config_path = data_dir.join("config.json");
5315    for gen in 0..BAK_GENERATIONS {
5316        let backup = backup_path_for(&config_path, gen);
5317        let content = match std::fs::read_to_string(&backup) {
5318            Ok(content) => content,
5319            Err(e) => {
5320                if e.kind() != std::io::ErrorKind::NotFound {
5321                    tracing::warn!(
5322                        "Failed to read {:?} while scanning for legacy connect data ({}); \
5323                         leaving it untouched",
5324                        backup,
5325                        e
5326                    );
5327                }
5328                continue;
5329            }
5330        };
5331        let mut value: serde_json::Value = match serde_json::from_str(&content) {
5332            Ok(value) => value,
5333            Err(e) => {
5334                tracing::warn!(
5335                    "Skipping unparsable backup {:?} while scanning for legacy connect data \
5336                     ({}); left untouched (never deleted)",
5337                    backup,
5338                    e
5339                );
5340                continue;
5341            }
5342        };
5343        let Some(obj) = value.as_object_mut() else {
5344            // Not a JSON object (e.g. `null`/an array) — nothing to strip, and
5345            // not a shape we should try to rewrite. Leave it alone.
5346            continue;
5347        };
5348        if obj.remove("connect").is_none() {
5349            // No legacy key in this generation — skip without writing so the
5350            // file's bytes/mtime are left completely untouched.
5351            continue;
5352        }
5353        let rewritten = match serde_json::to_string_pretty(&value) {
5354            Ok(rewritten) => rewritten,
5355            Err(e) => {
5356                tracing::error!(
5357                    "Failed to serialize {:?} after stripping legacy connect data: {}",
5358                    backup,
5359                    e
5360                );
5361                continue;
5362            }
5363        };
5364        match write_atomic(&backup, rewritten.as_bytes()) {
5365            Ok(()) => tracing::info!(
5366                "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5367                backup
5368            ),
5369            Err(e) => tracing::error!(
5370                "Failed to write {:?} after stripping legacy connect data: {}",
5371                backup,
5372                e
5373            ),
5374        }
5375    }
5376}
5377
5378/// Quarantine an unparsable `connect.json` to a single `connect.json.bak`
5379/// generation (best-effort) so the bad content survives for inspection
5380/// instead of being silently discarded. Unlike config.json's timestamped,
5381/// N-generation quarantine, connect.json only needs one slot — it's a much
5382/// smaller, less complex document and this is a fail-SAFE (empty/inert
5383/// bridge), not a fail-recover, posture. #455.
5384///
5385/// MOVES the corrupt file rather than copying it (#457): a copy would leave
5386/// the same corrupt `connect.json` sitting in the data dir right next to its
5387/// own quarantine copy, which reads as confusing/ambiguous mid-incident
5388/// (which one is live?). `rename` is used first (atomic, no partial-copy
5389/// window); if that fails — e.g. `connect.json.bak` and the data dir are on
5390/// different filesystems — fall back to copy-then-remove so the corrupt
5391/// original still doesn't linger.
5392fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5393    let backup = connect_path.with_extension("json.bak");
5394    match std::fs::rename(connect_path, &backup) {
5395        Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5396        Err(e) => {
5397            tracing::warn!(
5398                "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5399                backup,
5400                e
5401            );
5402            if let Err(e) = std::fs::copy(connect_path, &backup) {
5403                tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5404                return;
5405            }
5406            if let Err(e) = std::fs::remove_file(connect_path) {
5407                tracing::error!(
5408                    "Quarantined corrupt connect.json to {:?} but failed to remove the \
5409                     original {:?}: {}",
5410                    backup,
5411                    connect_path,
5412                    e
5413                );
5414            }
5415        }
5416    }
5417}
5418
5419/// How many `config.json.corrupted.*` quarantine files to keep. Each corrupt load
5420/// drops one; without a cap they accumulate unbounded. Newest `N` are retained.
5421const QUARANTINE_KEEP: usize = 5;
5422
5423/// Copy a corrupt config file aside to `config.json.corrupted.<nanos>` so the
5424/// user's (unparseable) configuration is preserved for inspection/recovery
5425/// instead of being silently discarded and then overwritten by defaults. #37.
5426///
5427/// Returns the quarantine path on success, so the caller can attach it to a
5428/// [`ConfigRecoveryStatus`] (#153); `None` if even the copy failed (the
5429/// corrupt original is still left in place at `config_path` regardless, since
5430/// this only ever copies, never moves/deletes).
5431fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5432    let nanos = std::time::SystemTime::now()
5433        .duration_since(std::time::UNIX_EPOCH)
5434        .map(|d| d.as_nanos())
5435        .unwrap_or(0);
5436    // Two corrupt loads in the same nanosecond would land on the same name and the
5437    // second `copy` would silently overwrite the first. Append a counter on
5438    // collision so each quarantine is preserved distinctly. #135.
5439    let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5440    let mut dedup = 1u32;
5441    while quarantine.exists() {
5442        quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5443        dedup += 1;
5444    }
5445    let result = match std::fs::copy(config_path, &quarantine) {
5446        Ok(_) => {
5447            tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5448            Some(quarantine)
5449        }
5450        Err(e) => {
5451            tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5452            None
5453        }
5454    };
5455    prune_quarantine_files(config_path, QUARANTINE_KEEP);
5456    result
5457}
5458
5459/// Keep only the newest `keep` `config.json.corrupted.*` files next to
5460/// `config_path`, deleting older ones so quarantines don't grow unbounded. #135.
5461fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5462    let Some(dir) = config_path.parent() else {
5463        return;
5464    };
5465    let prefix = "config.json.corrupted.";
5466    let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5467        Ok(entries) => entries
5468            .filter_map(|e| e.ok())
5469            .map(|e| e.path())
5470            .filter(|p| {
5471                p.file_name()
5472                    .and_then(|n| n.to_str())
5473                    .is_some_and(|n| n.starts_with(prefix))
5474            })
5475            .collect(),
5476        Err(_) => return,
5477    };
5478    if quarantines.len() <= keep {
5479        return;
5480    }
5481    // Oldest first (by mtime; missing mtime sorts oldest so it's pruned first).
5482    quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5483    let remove = quarantines.len() - keep;
5484    for stale in quarantines.into_iter().take(remove) {
5485        if let Err(e) = std::fs::remove_file(&stale) {
5486            tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5487        }
5488    }
5489}
5490
5491/// Number of `config.json.bak[.N]` generations to retain (`.bak` + `N-1` numbered).
5492/// More generations = more recovery points if a fresher backup is itself bad. #135.
5493const BAK_GENERATIONS: usize = 3;
5494
5495/// The on-disk path of backup generation `gen` (0 == `config.json.bak`).
5496fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5497    if gen == 0 {
5498        config_path.with_extension("json.bak")
5499    } else {
5500        config_path.with_extension(format!("json.bak.{gen}"))
5501    }
5502}
5503
5504/// Shift the backup generations down before a fresh `.bak` is written:
5505/// `.bak.(N-2) -> .bak.(N-1)`, …, `.bak -> .bak.1`. The oldest is overwritten by
5506/// the shift; the caller then writes the new `.bak`. Walks the highest (oldest)
5507/// destination slot first so no rename clobbers a slot a later move still needs to
5508/// read. Best-effort. #135.
5509fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5510    for gen in (1..generations).rev() {
5511        let from = backup_path_for(config_path, gen - 1);
5512        let to = backup_path_for(config_path, gen);
5513        if from.exists() {
5514            if let Err(e) = std::fs::rename(&from, &to) {
5515                tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5516            }
5517        }
5518    }
5519}
5520
5521pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5522    crate::config_store::AtomicFileStore::new(path)
5523        .write_bytes_without_backup(content)
5524        .map_err(|error| match error {
5525            crate::config_store::ConfigStoreError::Io(error) => error,
5526            other => std::io::Error::other(other),
5527        })
5528}
5529
5530#[cfg(test)]
5531mod tests {
5532    use super::*;
5533    use std::ffi::OsString;
5534    use std::path::PathBuf;
5535    use std::sync::Mutex;
5536    use std::time::{SystemTime, UNIX_EPOCH};
5537
5538    #[test]
5539    fn tools_config_preserves_unknown_keys_across_roundtrip() {
5540        let input = serde_json::json!({
5541            "disabled": ["bash"],
5542            "plugin_runtime": {
5543                "timeout_ms": 5_000,
5544                "sandbox": true
5545            },
5546            "future_flag": "enabled"
5547        });
5548
5549        let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5550        assert_eq!(config.disabled, vec!["bash"]);
5551        assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5552        assert_eq!(config.extra["future_flag"], "enabled");
5553        assert_eq!(serde_json::to_value(config).unwrap(), input);
5554    }
5555
5556    #[test]
5557    fn tools_config_keeps_section_when_only_unknown_keys_present() {
5558        let input = serde_json::json!({
5559            "tool_extension": {
5560                "mode": "strict",
5561                "options": ["one", "two"]
5562            }
5563        });
5564        let config: Config = serde_json::from_value(serde_json::json!({
5565            "tools": input.clone()
5566        }))
5567        .unwrap();
5568
5569        assert!(config.tools.disabled.is_empty());
5570        assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5571        assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5572
5573        let temp_home = TempHome::new();
5574        config.save_to_dir(temp_home.path.clone()).unwrap();
5575        let persisted: Value =
5576            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5577                .unwrap();
5578        assert_eq!(persisted["tools"], input);
5579    }
5580
5581    #[test]
5582    fn skills_config_preserves_unknown_keys_across_roundtrip() {
5583        let input = serde_json::json!({
5584            "external_catalog": {
5585                "path": "/opt/bamboo/skills",
5586                "refresh": false
5587            },
5588            "schema_version": 2
5589        });
5590
5591        let config: Config = serde_json::from_value(serde_json::json!({
5592            "skills": input.clone()
5593        }))
5594        .unwrap();
5595        assert!(config.skills.disabled.is_empty());
5596        assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5597        assert_eq!(config.skills.extra["schema_version"], 2);
5598        assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5599
5600        let temp_home = TempHome::new();
5601        config.save_to_dir(temp_home.path.clone()).unwrap();
5602        let persisted: Value =
5603            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5604                .unwrap();
5605        assert_eq!(persisted["skills"], input);
5606    }
5607
5608    #[test]
5609    fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5610        let config: Config = serde_json::from_value(serde_json::json!({
5611            "hooks": {
5612                "image_fallback": {"enabled": true, "mode": "placeholder"}
5613            },
5614            "lifecycle_hooks": {
5615                "enabled": true,
5616                "PreToolUse": [{
5617                    "matcher": "bash|write_file",
5618                    "hooks": [{"type": "command", "command": "guard.sh"}]
5619                }],
5620                "SessionStart": [{
5621                    "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5622                }]
5623            }
5624        }))
5625        .expect("lifecycle hook config should deserialize");
5626
5627        assert!(config.lifecycle_hooks.enabled);
5628        assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5629        assert!(
5630            config.lifecycle_hooks.pre_tool_use[0].enabled,
5631            "legacy groups without an enabled flag remain active"
5632        );
5633        assert_eq!(
5634            config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5635            DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5636        );
5637        assert_eq!(
5638            config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5639            25
5640        );
5641
5642        let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5643        assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5644        assert_eq!(
5645            json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5646            "bash|write_file"
5647        );
5648        assert_eq!(
5649            json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5650            "command"
5651        );
5652        assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5653            .get("enabled")
5654            .is_none());
5655        assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5656            .get("timeout_ms")
5657            .is_none());
5658        assert!(json.get("hooks").is_some());
5659    }
5660
5661    #[test]
5662    fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5663        let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5664            "type": "script",
5665            "path": ".bamboo/hooks/check.js"
5666        }))
5667        .expect("script lifecycle hook should deserialize");
5668
5669        assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5670        assert!(matches!(
5671            handler,
5672            LifecycleHookHandler::Script {
5673                runner: LifecycleScriptRunner::Auto,
5674                ..
5675            }
5676        ));
5677        let json = serde_json::to_value(handler).expect("script hook should serialize");
5678        assert_eq!(json["type"], "script");
5679        assert!(json.get("timeout_ms").is_none());
5680        assert!(json.get("runner").is_none());
5681    }
5682
5683    #[test]
5684    fn script_runner_support_is_extension_aware() {
5685        assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5686        assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5687        assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5688        assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5689        assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5690        assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5691        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5692        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5693        assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5694        assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5695    }
5696
5697    #[test]
5698    fn script_runner_names_round_trip_through_config_json() {
5699        for (runner, name) in [
5700            (LifecycleScriptRunner::Auto, "auto"),
5701            (LifecycleScriptRunner::Node, "node"),
5702            (LifecycleScriptRunner::Bun, "bun"),
5703            (LifecycleScriptRunner::Python, "python"),
5704            (LifecycleScriptRunner::Bash, "bash"),
5705            (LifecycleScriptRunner::PowerShell, "powershell"),
5706            (LifecycleScriptRunner::Cmd, "cmd"),
5707        ] {
5708            let json = serde_json::to_value(runner).unwrap();
5709            assert_eq!(json, name);
5710            assert_eq!(
5711                serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
5712                runner
5713            );
5714        }
5715    }
5716
5717    #[test]
5718    fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
5719        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5720        assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
5721        let json = serde_json::to_value(&config).unwrap();
5722        assert!(json.get("lifecycle_hooks").is_none());
5723    }
5724
5725    #[test]
5726    fn stream_timeout_defaults_are_safe_and_back_compatible() {
5727        let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
5728            .expect("legacy config without stream_timeout should load");
5729        let values = ConfigValues::from(root);
5730
5731        assert_eq!(
5732            values.stream_timeout,
5733            StreamTimeoutConfig {
5734                transport_idle_timeout_secs: 120,
5735                first_semantic_timeout_secs: 600,
5736                semantic_idle_timeout_secs: 600,
5737            }
5738        );
5739        values
5740            .stream_timeout
5741            .validate()
5742            .expect("defaults are valid");
5743    }
5744
5745    #[test]
5746    fn stream_timeout_round_trips_through_persistence_dto() {
5747        let values = ConfigValues {
5748            stream_timeout: StreamTimeoutConfig {
5749                transport_idle_timeout_secs: 45,
5750                first_semantic_timeout_secs: 900,
5751                semantic_idle_timeout_secs: 300,
5752            },
5753            ..ConfigValues::default()
5754        };
5755
5756        let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
5757        assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
5758        assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
5759        assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
5760
5761        let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
5762        assert_eq!(
5763            ConfigValues::from(root).stream_timeout,
5764            StreamTimeoutConfig {
5765                transport_idle_timeout_secs: 45,
5766                first_semantic_timeout_secs: 900,
5767                semantic_idle_timeout_secs: 300,
5768            }
5769        );
5770    }
5771
5772    #[test]
5773    fn context_management_defaults_to_summary_and_is_omitted() {
5774        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5775        assert_eq!(
5776            config.context_management.strategy,
5777            ContextManagementStrategy::Summary
5778        );
5779        assert_eq!(
5780            config
5781                .context_management
5782                .retrieval_window
5783                .min_recent_user_turns,
5784            3
5785        );
5786        assert_eq!(
5787            config.context_management.retrieval_target_usage_percent(),
5788            60
5789        );
5790        let json = serde_json::to_value(&config).unwrap();
5791        assert!(json.get("context_management").is_none());
5792    }
5793
5794    #[test]
5795    fn retrieval_window_context_management_round_trips() {
5796        let json = serde_json::json!({
5797            "context_management": {
5798                "strategy": "retrieval_window",
5799                "retrieval_window": {
5800                    "min_recent_user_turns": 4,
5801                    "trigger_usage_ratio": 0.82,
5802                    "target_usage_ratio": 0.61,
5803                    "history_tool_required": true,
5804                    "fallback_strategy": "summary"
5805                }
5806            }
5807        });
5808        let root: ConfigRoot = serde_json::from_value(json).unwrap();
5809        let values = ConfigValues::from(root);
5810        assert_eq!(
5811            values.context_management.strategy,
5812            ContextManagementStrategy::RetrievalWindow
5813        );
5814        assert_eq!(
5815            values.context_management.retrieval_window.fallback_strategy,
5816            ContextManagementFallbackStrategy::Summary
5817        );
5818
5819        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5820        assert_eq!(
5821            persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
5822            4
5823        );
5824        assert_eq!(
5825            persisted["context_management"]["strategy"],
5826            "retrieval_window"
5827        );
5828    }
5829
5830    #[test]
5831    fn retrieval_window_context_management_rejects_unsafe_policy() {
5832        for invalid in [
5833            serde_json::json!({
5834                "strategy": "retrieval_window",
5835                "retrieval_window": {"min_recent_user_turns": 0}
5836            }),
5837            serde_json::json!({
5838                "strategy": "retrieval_window",
5839                "retrieval_window": {
5840                    "target_usage_ratio": 0.8,
5841                    "trigger_usage_ratio": 0.8
5842                }
5843            }),
5844            serde_json::json!({
5845                "strategy": "retrieval_window",
5846                "retrieval_window": {
5847                    "target_usage_ratio": 0.005,
5848                    "trigger_usage_ratio": 0.006
5849                }
5850            }),
5851            serde_json::json!({
5852                "strategy": "retrieval_window",
5853                "retrieval_window": {"history_tool_required": false}
5854            }),
5855        ] {
5856            serde_json::from_value::<ContextManagementConfig>(invalid)
5857                .expect_err("unsafe retrieval-window policy must fail closed");
5858        }
5859    }
5860
5861    #[test]
5862    fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
5863        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
5864            "provider_type": "openai",
5865            "enabled": true
5866        }))
5867        .unwrap();
5868        let mut config = Config::default();
5869        config.values.provider = "gemini".to_string();
5870        config
5871            .provider_instances
5872            .insert("work".to_string(), instance);
5873        config.default_provider_instance = Some("work".to_string());
5874        config.providers_mut().openai = Some(OpenAIConfig::default());
5875
5876        let json = serde_json::to_value(&config).unwrap();
5877        assert_eq!(json["provider"], "gemini");
5878        assert!(json["providers"]["openai"].is_object());
5879        assert_eq!(json["default_provider_instance"], "work");
5880
5881        let round_trip: Config = serde_json::from_value(json).unwrap();
5882        assert_eq!(round_trip.provider, "gemini");
5883        assert_eq!(
5884            round_trip.default_provider_instance.as_deref(),
5885            Some("work")
5886        );
5887        assert!(round_trip.provider_instances.contains_key("work"));
5888        assert!(round_trip.providers().openai.is_some());
5889    }
5890
5891    #[test]
5892    fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
5893        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
5894        let dir = tempfile::tempdir().unwrap();
5895        let mut config = Config::default();
5896        config.values.provider = "anthropic".to_string();
5897        config.provider_instances.insert(
5898            "work".to_string(),
5899            serde_json::from_value(serde_json::json!({
5900                "provider_type": "openai",
5901                "model": "gpt-instance",
5902                "enabled": true,
5903                "future_instance_metadata": "preserved"
5904            }))
5905            .unwrap(),
5906        );
5907        config.default_provider_instance = Some("work".to_string());
5908        config.features.provider_model_ref = true;
5909        config.providers_mut().openai = Some(OpenAIConfig::default());
5910        config.providers_mut().anthropic = Some(AnthropicConfig::default());
5911        config
5912            .providers_mut()
5913            .extra
5914            .insert("provider".to_string(), serde_json::json!("anthropic"));
5915        config.providers_mut().extra.insert(
5916            "future_provider".to_string(),
5917            serde_json::json!({"kept": true}),
5918        );
5919
5920        let (root_bytes, provider_bytes) =
5921            config.prepare_provider_transaction_documents(&[]).unwrap();
5922        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
5923        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
5924        assert!(root.get("provider").is_none());
5925        assert!(root.get("providers").is_none());
5926        assert_eq!(root["default_provider_instance"], "work");
5927        assert_eq!(
5928            root["provider_instances"]["work"]["future_instance_metadata"],
5929            "preserved"
5930        );
5931        assert!(providers.get("provider").is_none());
5932        for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
5933            assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
5934        }
5935        assert_eq!(providers["future_provider"]["kept"], true);
5936
5937        config.save_to_dir(dir.path().to_path_buf()).unwrap();
5938        let saved_root: Value =
5939            serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
5940                .unwrap();
5941        let saved_providers: Value =
5942            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
5943                .unwrap();
5944        assert!(saved_root.get("provider").is_none());
5945        assert!(saved_root.get("providers").is_none());
5946        assert_eq!(saved_root["default_provider_instance"], "work");
5947        assert!(saved_providers.get("openai").is_none());
5948        assert!(saved_providers.get("anthropic").is_none());
5949        assert!(saved_providers.get("provider").is_none());
5950        assert_eq!(saved_providers["future_provider"]["kept"], true);
5951
5952        config.providers_mut().gemini = Some(GeminiConfig::default());
5953        config.save_providers_to_dir(dir.path()).unwrap();
5954        let provider_only: Value =
5955            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
5956                .unwrap();
5957        assert!(provider_only.get("gemini").is_none());
5958        assert!(provider_only.get("provider").is_none());
5959        assert_eq!(provider_only["future_provider"]["kept"], true);
5960    }
5961
5962    #[test]
5963    fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
5964        let mut config = Config::default();
5965        config.values.provider = "openai".to_string();
5966        config.providers_mut().openai = Some(OpenAIConfig {
5967            model: Some("gpt-legacy".to_string()),
5968            ..OpenAIConfig::default()
5969        });
5970        config.provider_instances.insert(
5971            "work".to_string(),
5972            serde_json::from_value(serde_json::json!({
5973                "provider_type": "copilot",
5974                "enabled": true
5975            }))
5976            .unwrap(),
5977        );
5978        config.default_provider_instance = Some("openai".to_string());
5979
5980        let (root_bytes, provider_bytes) =
5981            config.prepare_provider_transaction_documents(&[]).unwrap();
5982        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
5983        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
5984        assert_eq!(root["provider"], "openai");
5985        assert_eq!(root["default_provider_instance"], "openai");
5986        assert!(root["provider_instances"]["work"].is_object());
5987        assert_eq!(providers["openai"]["model"], "gpt-legacy");
5988    }
5989
5990    #[test]
5991    fn persistence_keeps_legacy_provider_without_instance_default() {
5992        let values = ConfigValues {
5993            provider: "gemini".to_string(),
5994            ..ConfigValues::default()
5995        };
5996
5997        let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5998        assert_eq!(json["provider"], "gemini");
5999    }
6000
6001    #[test]
6002    fn stream_timeout_rejects_zero_and_unbounded_values() {
6003        for (field, invalid) in [
6004            ("transport_idle_timeout_secs", 0),
6005            ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6006            ("semantic_idle_timeout_secs", 0),
6007        ] {
6008            let mut timeout = serde_json::json!({});
6009            timeout[field] = serde_json::json!(invalid);
6010            let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6011                .expect_err("invalid timeout must be rejected");
6012            assert!(error.to_string().contains("stream timeout must be between"));
6013        }
6014    }
6015
6016    struct EnvVarGuard {
6017        key: &'static str,
6018        previous: Option<OsString>,
6019    }
6020
6021    impl EnvVarGuard {
6022        fn set(key: &'static str, value: &str) -> Self {
6023            let previous = std::env::var_os(key);
6024            std::env::set_var(key, value);
6025            Self { key, previous }
6026        }
6027
6028        fn unset(key: &'static str) -> Self {
6029            let previous = std::env::var_os(key);
6030            std::env::remove_var(key);
6031            Self { key, previous }
6032        }
6033    }
6034
6035    impl Drop for EnvVarGuard {
6036        fn drop(&mut self) {
6037            match &self.previous {
6038                Some(value) => std::env::set_var(self.key, value),
6039                None => std::env::remove_var(self.key),
6040            }
6041        }
6042    }
6043
6044    #[test]
6045    fn run_budget_config_merge_is_tighten_only_per_field() {
6046        let config_default = RunBudgetConfig {
6047            max_total_tokens: Some(100_000),
6048            max_tool_calls: Some(500),
6049            max_subagents: Some(10),
6050        };
6051
6052        // No override at all: config default passes through unchanged.
6053        assert_eq!(
6054            config_default.merged_with_override(None),
6055            config_default,
6056            "no override falls back to the config default entirely"
6057        );
6058
6059        // Override TIGHTENS exactly one field; the other two keep the config
6060        // default (per-field, not all-or-nothing).
6061        let tighten_one = RunBudgetConfig {
6062            max_total_tokens: Some(5_000),
6063            max_tool_calls: None,
6064            max_subagents: None,
6065        };
6066        let merged = config_default.merged_with_override(Some(&tighten_one));
6067        assert_eq!(merged.max_total_tokens, Some(5_000));
6068        assert_eq!(merged.max_tool_calls, Some(500));
6069        assert_eq!(merged.max_subagents, Some(10));
6070
6071        // A LOOSER override is clamped to the config default: a client can
6072        // never raise the operator's ceiling (PR #539 review, finding #3).
6073        let loosen_attempt = RunBudgetConfig {
6074            max_total_tokens: Some(999_999_999),
6075            max_tool_calls: Some(10_000),
6076            max_subagents: Some(1_000),
6077        };
6078        assert_eq!(
6079            config_default.merged_with_override(Some(&loosen_attempt)),
6080            config_default,
6081            "looser per-request values must be clamped to the config ceiling"
6082        );
6083
6084        // Nor can it REMOVE a configured ceiling by omitting the field: an
6085        // absent override field keeps the config default, it does not mean
6086        // unlimited.
6087        let empty_override = RunBudgetConfig::default();
6088        assert_eq!(
6089            config_default.merged_with_override(Some(&empty_override)),
6090            config_default,
6091            "an all-absent override body keeps every configured ceiling"
6092        );
6093
6094        // An unlimited config default CAN be tightened by the request (the
6095        // request is the only ceiling then), and stays unlimited on fields the
6096        // request does not set.
6097        let unlimited_default = RunBudgetConfig::default();
6098        let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6099        assert_eq!(merged.max_total_tokens, Some(5_000));
6100        assert_eq!(merged.max_tool_calls, None);
6101        assert_eq!(merged.max_subagents, None);
6102    }
6103
6104    #[test]
6105    fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6106        assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6107        assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6108        assert_eq!(RunBudgetConfig::default().max_subagents, None);
6109
6110        let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6111        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6112        assert_eq!(cfg.max_total_tokens, Some(250_000));
6113        assert_eq!(
6114            cfg.max_tool_calls, None,
6115            "absent field defaults to unlimited"
6116        );
6117        assert_eq!(cfg.max_subagents, Some(3));
6118
6119        // Absent fields are omitted on serialize (skip_serializing_if), so an
6120        // all-default config round-trips to `{}` rather than three explicit
6121        // nulls.
6122        let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6123        assert_eq!(empty, "{}");
6124    }
6125
6126    #[test]
6127    fn subagents_config_without_remote_placements_deserializes_empty() {
6128        // An OLD config (predating P1.5) has no `remote_placements` key — it must
6129        // still deserialize, with an empty placement list (default = local path).
6130        let json = r#"{ "max_concurrent": 4 }"#;
6131        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6132        assert_eq!(cfg.max_concurrent, Some(4));
6133        assert!(cfg.remote_placements.is_empty());
6134        // And an empty placement list is omitted on re-serialize (skip_if empty).
6135        let back = serde_json::to_string(&cfg).unwrap();
6136        assert!(
6137            !back.contains("remote_placements"),
6138            "empty vec is skipped: {back}"
6139        );
6140    }
6141
6142    #[test]
6143    fn remote_actor_placement_round_trips() {
6144        let json = r#"{
6145            "remote_placements": [
6146                {
6147                    "role": "explorer",
6148                    "endpoint": "wss://gpu-host:8443",
6149                    "token_env": "WORKER_TOKEN",
6150                    "ca_cert_file": "/etc/bamboo/worker.pem"
6151                },
6152                { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6153            ]
6154        }"#;
6155        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6156        assert_eq!(cfg.remote_placements.len(), 2);
6157        let p0 = &cfg.remote_placements[0];
6158        assert_eq!(p0.role, "explorer");
6159        assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6160        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6161        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6162        // Optional fields default to None and are skipped on serialize.
6163        let p1 = &cfg.remote_placements[1];
6164        assert_eq!(p1.role, "writer");
6165        assert!(p1.token_env.is_none());
6166        assert!(p1.ca_cert_file.is_none());
6167
6168        let back = serde_json::to_string(&cfg).unwrap();
6169        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6170        assert_eq!(cfg, reparsed, "round-trip is stable");
6171        assert!(!back.contains("\"token_env\":null"));
6172        assert!(!back.contains("\"ca_cert_file\":null"));
6173    }
6174
6175    #[test]
6176    fn subagents_config_without_schedulable_placements_deserializes_empty() {
6177        // An OLD config (predating P2b) has no `schedulable_placements` key — it
6178        // must still deserialize, with an empty list (default = local path).
6179        let json = r#"{ "max_concurrent": 4 }"#;
6180        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6181        assert!(cfg.schedulable_placements.is_empty());
6182        // An empty list is omitted on re-serialize (skip_if empty).
6183        let back = serde_json::to_string(&cfg).unwrap();
6184        assert!(
6185            !back.contains("schedulable_placements"),
6186            "empty vec is skipped: {back}"
6187        );
6188    }
6189
6190    #[test]
6191    fn schedulable_placement_round_trips() {
6192        let json = r#"{
6193            "schedulable_placements": [
6194                {
6195                    "role": "explorer",
6196                    "pool": "gpu-pool",
6197                    "registry_url": "https://control-plane:9562",
6198                    "token_env": "WORKER_TOKEN",
6199                    "ca_cert_file": "/etc/bamboo/worker.pem"
6200                },
6201                { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6202            ]
6203        }"#;
6204        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6205        assert_eq!(cfg.schedulable_placements.len(), 2);
6206        let p0 = &cfg.schedulable_placements[0];
6207        assert_eq!(p0.role, "explorer");
6208        assert_eq!(p0.pool, "gpu-pool");
6209        assert_eq!(p0.registry_url, "https://control-plane:9562");
6210        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6211        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6212        // Optional fields default to None and are skipped on serialize.
6213        let p1 = &cfg.schedulable_placements[1];
6214        assert_eq!(p1.role, "writer");
6215        assert_eq!(p1.pool, "cpu-pool");
6216        assert!(p1.token_env.is_none());
6217        assert!(p1.ca_cert_file.is_none());
6218
6219        let back = serde_json::to_string(&cfg).unwrap();
6220        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6221        assert_eq!(cfg, reparsed, "round-trip is stable");
6222        assert!(!back.contains("\"token_env\":null"));
6223        assert!(!back.contains("\"ca_cert_file\":null"));
6224    }
6225
6226    #[test]
6227    fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6228        // An OLD config (predating #54's wiring) has no `mcp_role_allowlist`
6229        // key — it must still deserialize, with an empty list (default =
6230        // every role unrestricted, identical to pre-#54 behavior).
6231        let json = r#"{ "max_concurrent": 4 }"#;
6232        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6233        assert!(cfg.mcp_role_allowlist.is_empty());
6234        // An empty list is omitted on re-serialize (skip_if empty).
6235        let back = serde_json::to_string(&cfg).unwrap();
6236        assert!(
6237            !back.contains("mcp_role_allowlist"),
6238            "empty vec is skipped: {back}"
6239        );
6240    }
6241
6242    #[test]
6243    fn mcp_role_allowlist_entry_round_trips() {
6244        let json = r#"{
6245            "mcp_role_allowlist": [
6246                { "role": "researcher", "tools": ["fetch_url"] },
6247                { "role": "sandboxed", "tools": [] }
6248            ]
6249        }"#;
6250        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6251        assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6252        assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6253        assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6254        // An empty `tools` list is an explicit lockout, distinct from the role
6255        // being absent — it must round-trip as an empty (not omitted) list.
6256        assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6257        assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6258
6259        let back = serde_json::to_string(&cfg).unwrap();
6260        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6261        assert_eq!(cfg, reparsed, "round-trip is stable");
6262    }
6263
6264    #[test]
6265    fn server_config_without_tls_field_deserializes_back_compat() {
6266        // An old config.json `server` section with no `tls` key must still
6267        // deserialize, leaving `tls` as None (zero behavior change on upgrade).
6268        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6269            "port": 9562,
6270            "bind": "127.0.0.1"
6271        }))
6272        .expect("legacy server config without tls should deserialize");
6273
6274        assert_eq!(server.tls, None);
6275        assert_eq!(server.port, 9562);
6276        assert_eq!(server.bind, "127.0.0.1");
6277    }
6278
6279    #[test]
6280    fn server_config_omits_tls_when_none() {
6281        // `skip_serializing_if = "Option::is_none"` keeps the on-disk shape
6282        // identical to before for the common (no-TLS) case.
6283        let server = ServerConfig::default();
6284        let value = serde_json::to_value(&server).expect("server config should serialize");
6285        let obj = value
6286            .as_object()
6287            .expect("server config serializes to object");
6288        assert!(
6289            !obj.contains_key("tls"),
6290            "tls must be omitted when None, got: {value}"
6291        );
6292    }
6293
6294    #[test]
6295    fn server_config_with_tls_roundtrips() {
6296        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6297            "port": 9562,
6298            "bind": "0.0.0.0",
6299            "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6300        }))
6301        .expect("server config with tls should deserialize");
6302
6303        let tls = server.tls.clone().expect("tls should be Some");
6304        assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6305        assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6306
6307        // Round-trips: tls survives a serialize → deserialize cycle.
6308        let value = serde_json::to_value(&server).expect("serialize");
6309        assert!(value.as_object().unwrap().contains_key("tls"));
6310        let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6311        assert_eq!(back.tls, server.tls);
6312    }
6313
6314    #[test]
6315    fn access_control_without_devices_field_deserializes_back_compat() {
6316        // An old config.json `access_control` with no `devices` key must still
6317        // deserialize, leaving `devices` empty (root-password-only mode).
6318        let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6319            "password_enabled": true,
6320            "password_hash": "deadbeef",
6321            "password_salt": "01020304",
6322        }))
6323        .expect("legacy access_control without devices should deserialize");
6324
6325        assert!(access.devices.is_empty());
6326        assert!(access.password_enabled);
6327    }
6328
6329    #[test]
6330    fn access_control_omits_devices_when_empty() {
6331        // `skip_serializing_if = "Vec::is_empty"` keeps the on-disk shape
6332        // identical for instances that never paired a device.
6333        let access = AccessControlConfig {
6334            password_enabled: true,
6335            repair_required: false,
6336            password_hash: Some("deadbeef".to_string()),
6337            password_salt: Some("01020304".to_string()),
6338            password_credential_ref: None,
6339            password_configured: false,
6340            updated_at: None,
6341            devices: Vec::new(),
6342        };
6343        let value = serde_json::to_value(&access).expect("serialize");
6344        let obj = value.as_object().expect("object");
6345        assert!(
6346            !obj.contains_key("devices"),
6347            "devices must be omitted when empty, got: {value}"
6348        );
6349    }
6350
6351    #[test]
6352    fn access_control_with_devices_roundtrips() {
6353        let device = DeviceCredential {
6354            device_id: "bamboo_0123456789ab".to_string(),
6355            label: "iPhone 15".to_string(),
6356            token_hash: "abcd".to_string(),
6357            token_salt: "ef01".to_string(),
6358            token_credential_ref: None,
6359            token_configured: false,
6360            created_at: "2026-06-23T00:00:00Z".to_string(),
6361            last_used_at: None,
6362            revoked: false,
6363        };
6364        let access = AccessControlConfig {
6365            password_enabled: true,
6366            repair_required: false,
6367            password_hash: Some("deadbeef".to_string()),
6368            password_salt: Some("01020304".to_string()),
6369            password_credential_ref: None,
6370            password_configured: false,
6371            updated_at: None,
6372            devices: vec![device.clone()],
6373        };
6374        let value = serde_json::to_value(&access).expect("serialize");
6375        assert!(value.as_object().unwrap().contains_key("devices"));
6376        assert!(value["devices"][0].get("token_hash").is_none());
6377        assert!(value["devices"][0].get("token_salt").is_none());
6378        let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6379        assert_eq!(back.devices[0].device_id, device.device_id);
6380        assert!(back.devices[0].token_hash.is_empty());
6381        assert!(back.devices[0].token_salt.is_empty());
6382    }
6383
6384    #[test]
6385    fn reasoning_effort_for_key_resolves_instance_id() {
6386        // Multi-instance mode: the routing key is an instance id and the effort
6387        // lives under provider_instances[<id>] — previously this fell through to
6388        // None because the resolver only matched literal provider types.
6389        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6390            "provider_type": "copilot",
6391            "reasoning_effort": "high",
6392        }))
6393        .expect("instance config should deserialize");
6394
6395        let mut config = Config::create_default();
6396        config
6397            .provider_instances
6398            .insert("copilot-work".to_string(), instance);
6399
6400        assert_eq!(
6401            config.reasoning_effort_for_key("copilot-work"),
6402            Some(ReasoningEffort::High),
6403        );
6404    }
6405
6406    #[test]
6407    fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6408        // Legacy mode: the `bodhi` provider previously had no match arm.
6409        let mut config = Config::create_default();
6410        config.providers.bodhi = Some(
6411            serde_json::from_value(serde_json::json!({
6412                "reasoning_effort": "xhigh",
6413            }))
6414            .expect("bodhi config should deserialize"),
6415        );
6416
6417        assert_eq!(
6418            config.reasoning_effort_for_key("bodhi"),
6419            Some(ReasoningEffort::Xhigh),
6420        );
6421    }
6422
6423    #[test]
6424    fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6425        let mut config = Config::create_default();
6426        config.providers.openai = Some(
6427            serde_json::from_value(serde_json::json!({
6428                "api_key": "sk-test",
6429                "reasoning_effort": "low",
6430            }))
6431            .expect("openai config should deserialize"),
6432        );
6433
6434        assert_eq!(
6435            config.reasoning_effort_for_key("openai"),
6436            Some(ReasoningEffort::Low),
6437        );
6438    }
6439
6440    #[test]
6441    fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6442        let config = Config::create_default();
6443        assert_eq!(config.reasoning_effort_for_key("nope"), None);
6444        assert_eq!(config.reasoning_effort_for_key("   "), None);
6445    }
6446
6447    struct TempHome {
6448        path: PathBuf,
6449    }
6450
6451    impl TempHome {
6452        fn new() -> Self {
6453            // `pid + nanos` alone is NOT collision-free (issue #486): every
6454            // test in this binary shares the pid, and two tests started
6455            // concurrently by the multi-threaded harness can observe the
6456            // same `SystemTime` nanos tick. Two `TempHome`s colliding on one
6457            // path means they share a directory — and the first test's
6458            // `Drop` (`remove_dir_all`) then yanks the directory out from
6459            // under the other test's in-flight `save_to_dir`, whose
6460            // tmp-file+rename dance fails with ENOENT ("Failed to write
6461            // config file ... os error 2" — `save_rotates_backup_generations`'s
6462            // exact one-off CI failure mode). A per-process atomic counter
6463            // in the name makes each instance unique unconditionally.
6464            static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6465                std::sync::atomic::AtomicU64::new(0);
6466            let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6467            let nanos = SystemTime::now()
6468                .duration_since(UNIX_EPOCH)
6469                .expect("clock should be after unix epoch")
6470                .as_nanos();
6471            let path = std::env::temp_dir().join(format!(
6472                "chat-core-config-test-{}-{}-{}",
6473                std::process::id(),
6474                nanos,
6475                unique
6476            ));
6477            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6478            Self { path }
6479        }
6480
6481        fn set_config_json(&self, content: &str) {
6482            // Treat `path` as the Bamboo data dir and write `config.json` into it.
6483            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
6484            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
6485            std::fs::write(self.path.join("config.json"), content)
6486                .expect("failed to write config.json");
6487        }
6488    }
6489
6490    impl Drop for TempHome {
6491        fn drop(&mut self) {
6492            let _ = std::fs::remove_dir_all(&self.path);
6493        }
6494    }
6495
6496    // Delegate to the single crate-wide test lock so env-mutating tests across
6497    // `config`, `encryption`, and `paths` serialize against one another (they
6498    // all mutate the same process-global env / static caches).
6499    fn env_lock() -> &'static Mutex<()> {
6500        crate::test_support::env_cache_lock()
6501    }
6502
6503    /// Acquire the environment lock, recovering from poison if a previous test failed
6504    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
6505        env_lock().lock().unwrap_or_else(|poisoned| {
6506            // Lock was poisoned by a previous test failure - recover it
6507            poisoned.into_inner()
6508        })
6509    }
6510
6511    #[test]
6512    fn parse_bool_env_true_values() {
6513        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
6514            assert!(parse_bool_env(value), "value {value:?} should be true");
6515        }
6516    }
6517
6518    #[test]
6519    fn parse_bool_env_false_values() {
6520        for value in ["0", "false", "no", "off", "", "  "] {
6521            assert!(!parse_bool_env(value), "value {value:?} should be false");
6522        }
6523    }
6524
6525    #[test]
6526    fn config_new_ignores_http_proxy_env_vars() {
6527        let _lock = env_lock_acquire();
6528        let temp_home = TempHome::new();
6529        temp_home.set_config_json(
6530            r#"{
6531  "http_proxy": "",
6532  "https_proxy": ""
6533}"#,
6534        );
6535
6536        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
6537        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
6538
6539        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6540
6541        assert!(
6542            config.http_proxy.is_empty(),
6543            "config should ignore HTTP_PROXY env var"
6544        );
6545        assert!(
6546            config.https_proxy.is_empty(),
6547            "config should ignore HTTPS_PROXY env var"
6548        );
6549    }
6550
6551    #[test]
6552    fn config_new_loads_config_when_proxy_fields_omitted() {
6553        let _lock = env_lock_acquire();
6554        let temp_home = TempHome::new();
6555        temp_home.set_config_json(
6556            r#"{
6557  "provider": "openai",
6558  "providers": {
6559    "openai": {
6560      "api_key": "sk-test",
6561      "model": "gpt-4o"
6562    }
6563  }
6564}"#,
6565        );
6566
6567        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
6568        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
6569
6570        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6571
6572        assert_eq!(
6573            config
6574                .providers
6575                .openai
6576                .as_ref()
6577                .and_then(|c| c.model.as_deref()),
6578            Some("gpt-4o"),
6579            "config should load provider model from config file even when proxy fields are omitted"
6580        );
6581        assert!(config.http_proxy.is_empty());
6582        assert!(config.https_proxy.is_empty());
6583    }
6584
6585    #[test]
6586    fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
6587        let _lock = crate::test_support::env_cache_lock_acquire();
6588        let mut config = Config::default();
6589        config.env_vars.extend([
6590            EnvVarEntry {
6591                name: "SECRET_TOKEN".to_string(),
6592                value: "top-secret".to_string(),
6593                secret: true,
6594                value_encrypted: None,
6595                credential_ref: None,
6596                configured: true,
6597                description: Some("Service token".to_string()),
6598            },
6599            EnvVarEntry {
6600                name: "API_BASE".to_string(),
6601                value: "https://internal.example".to_string(),
6602                secret: false,
6603                value_encrypted: None,
6604                credential_ref: None,
6605                configured: true,
6606                description: Some("Internal API base".to_string()),
6607            },
6608        ]);
6609
6610        config.publish_env_vars();
6611
6612        let injected = Config::current_env_vars();
6613        assert_eq!(
6614            injected.get("SECRET_TOKEN").map(String::as_str),
6615            Some("top-secret")
6616        );
6617        assert_eq!(
6618            injected.get("API_BASE").map(String::as_str),
6619            Some("https://internal.example")
6620        );
6621
6622        let prompt_safe = Config::current_prompt_safe_env_vars();
6623        assert_eq!(prompt_safe.len(), 2);
6624        assert!(prompt_safe.iter().any(|entry| {
6625            entry.name == "SECRET_TOKEN"
6626                && entry.secret
6627                && entry.description.as_deref() == Some("Service token")
6628        }));
6629        assert!(prompt_safe.iter().any(|entry| {
6630            entry.name == "API_BASE"
6631                && !entry.secret
6632                && entry.description.as_deref() == Some("Internal API base")
6633        }));
6634        assert!(!prompt_safe
6635            .iter()
6636            .any(|entry| entry.name.contains("top-secret")));
6637        assert!(!prompt_safe.iter().any(|entry| {
6638            entry
6639                .description
6640                .as_deref()
6641                .is_some_and(|value| value.contains("https://internal.example"))
6642        }));
6643    }
6644
6645    #[test]
6646    fn from_data_dir_without_publish_does_not_clobber_global_cache() {
6647        let _lock = crate::test_support::env_cache_lock_acquire();
6648
6649        // Seed the global cache with a marker "owned" by the live config.
6650        let mut live = Config::default();
6651        live.env_vars.extend([EnvVarEntry {
6652            name: "BAMBOO_CACHE_OWNER_40".to_string(),
6653            value: "live".to_string(),
6654            secret: false,
6655            value_encrypted: None,
6656            credential_ref: None,
6657            configured: true,
6658            description: None,
6659        }]);
6660        live.publish_env_vars();
6661        assert_eq!(
6662            Config::current_env_vars()
6663                .get("BAMBOO_CACHE_OWNER_40")
6664                .map(String::as_str),
6665            Some("live")
6666        );
6667
6668        // A config.json on disk sets the SAME var to a different (stale) value.
6669        let temp = TempHome::new();
6670        temp.set_config_json(
6671            &serde_json::json!({
6672                "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
6673            })
6674            .to_string(),
6675        );
6676
6677        // Non-publishing load reads the disk value into the returned Config but
6678        // must NOT touch the global cache.
6679        let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6680        assert_eq!(
6681            loaded
6682                .env_vars
6683                .iter()
6684                .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
6685                .map(|e| e.value.as_str()),
6686            Some("stale-disk"),
6687            "the returned Config holds the disk value"
6688        );
6689        assert_eq!(
6690            Config::current_env_vars()
6691                .get("BAMBOO_CACHE_OWNER_40")
6692                .map(String::as_str),
6693            Some("live"),
6694            "but the global cache is UNTOUCHED — no clobber (#40)"
6695        );
6696
6697        // Contrast: the publishing variant DOES clobber the cache.
6698        let _ = Config::from_data_dir(Some(temp.path.clone()));
6699        assert_eq!(
6700            Config::current_env_vars()
6701                .get("BAMBOO_CACHE_OWNER_40")
6702                .map(String::as_str),
6703            Some("stale-disk"),
6704            "the publishing loader clobbers the cache (contrast)"
6705        );
6706    }
6707
6708    fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
6709        std::fs::read_dir(dir)
6710            .unwrap()
6711            .filter_map(|e| e.ok())
6712            .any(|e| {
6713                e.file_name()
6714                    .to_string_lossy()
6715                    .contains("config.json.corrupted.")
6716            })
6717    }
6718
6719    #[test]
6720    fn corrupt_config_recovered_from_backup_and_quarantined() {
6721        let temp = TempHome::new();
6722        // Last-known-good backup with a distinctive value.
6723        std::fs::write(
6724            temp.path.join("config.json.bak"),
6725            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6726        )
6727        .unwrap();
6728        // Corrupt primary config.json.
6729        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6730
6731        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6732        assert_eq!(
6733            config.http_proxy, "http://from-backup",
6734            "recovered from config.json.bak instead of losing all config"
6735        );
6736        assert!(
6737            dir_has_quarantine_file(&temp.path),
6738            "corrupt config.json was quarantined (preserved), not discarded"
6739        );
6740    }
6741
6742    #[test]
6743    fn corrupt_config_without_backup_quarantines_then_defaults() {
6744        let temp = TempHome::new();
6745        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6746
6747        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6748        assert!(
6749            config.http_proxy.is_empty(),
6750            "no backup -> falls back to defaults"
6751        );
6752        assert!(
6753            dir_has_quarantine_file(&temp.path),
6754            "corrupt config.json is quarantined even when there's no backup"
6755        );
6756    }
6757
6758    #[test]
6759    fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
6760        let temp = TempHome::new();
6761        // A valid JSON OBJECT, but `env_vars` is the wrong type (string, not array)
6762        // so STRICT parse fails. There is NO config.json.bak, so recovery must come
6763        // from field-level salvage: `http_proxy` is valid and must survive; the bad
6764        // `env_vars` resets to its default.
6765        temp.set_config_json(
6766            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6767        );
6768
6769        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6770        assert_eq!(
6771            config.http_proxy, "http://salvaged",
6772            "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
6773        );
6774        assert!(
6775            config.env_vars.is_empty(),
6776            "the corrupt field reset to its default instead of failing the whole load"
6777        );
6778        assert!(
6779            dir_has_quarantine_file(&temp.path),
6780            "the corrupt config.json was still quarantined for inspection"
6781        );
6782    }
6783
6784    #[test]
6785    fn salvage_preferred_over_backup_for_most_recent_intent() {
6786        let temp = TempHome::new();
6787        // An OLDER last-known-good backup...
6788        std::fs::write(
6789            temp.path.join("config.json.bak"),
6790            serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
6791        )
6792        .unwrap();
6793        // ...and a NEWER config that is corrupt but field-salvageable.
6794        temp.set_config_json(
6795            r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
6796        );
6797
6798        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6799        assert_eq!(
6800            config.http_proxy, "http://new-salvaged",
6801            "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
6802        );
6803    }
6804
6805    #[test]
6806    fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
6807        let temp = TempHome::new();
6808        // Backup carries TWO good values.
6809        std::fs::write(
6810            temp.path.join("config.json.bak"),
6811            serde_json::json!({
6812                "http_proxy": "http://old-from-backup",
6813                "https_proxy": "https://kept-from-backup",
6814            })
6815            .to_string(),
6816        )
6817        .unwrap();
6818        // The corrupt file updates http_proxy (newer), leaves https_proxy untouched,
6819        // and has one wrong-type field.
6820        temp.set_config_json(
6821            r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
6822        );
6823
6824        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6825        // Best of both: the corrupt file's newer valid edit wins where it set one...
6826        assert_eq!(
6827            config.http_proxy, "http://newer-edit",
6828            "the corrupt file's newer valid edit is applied"
6829        );
6830        // ...and the backup's value survives for fields the corrupt file didn't fix.
6831        assert_eq!(
6832            config.https_proxy, "https://kept-from-backup",
6833            "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
6834        );
6835    }
6836
6837    #[test]
6838    fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
6839        let temp = TempHome::new();
6840        std::fs::write(
6841            temp.path.join("config.json.bak"),
6842            serde_json::json!({
6843                "providers": {
6844                    "anthropic": { "model": "claude-backup" }
6845                },
6846                "memory": {
6847                    "auto_dream_enabled": true
6848                },
6849                "subagents": {
6850                    "claude_code_model": "claude-code-backup"
6851                }
6852            })
6853            .to_string(),
6854        )
6855        .unwrap();
6856        assert!(!temp.path.join("providers.json").exists());
6857        assert!(!temp.path.join("memory.json").exists());
6858        assert!(!temp.path.join("subagents.json").exists());
6859
6860        let (salvaged, recovered_fields) = Config::salvage_partial(
6861            r#"{"providers":"schema-invalid-provider-section"}"#,
6862            &temp.path,
6863        )
6864        .expect("object-shaped corrupt config should be salvageable");
6865
6866        assert!(
6867            recovered_fields.is_empty(),
6868            "the schema-invalid provider field must not replace the backup baseline"
6869        );
6870        assert_eq!(
6871            salvaged
6872                .providers()
6873                .anthropic
6874                .as_ref()
6875                .and_then(|provider| provider.model.as_deref()),
6876            Some("claude-backup")
6877        );
6878        assert!(
6879            salvaged
6880                .memory()
6881                .as_ref()
6882                .expect("backup memory config survives")
6883                .auto_dream_enabled
6884        );
6885        assert_eq!(
6886            salvaged.subagents().claude_code_model.as_deref(),
6887            Some("claude-code-backup")
6888        );
6889    }
6890
6891    #[test]
6892    fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
6893        let temp = TempHome::new();
6894        // Not even a JSON object -> nothing field-wise to salvage -> must fall
6895        // through to the .bak (the pre-#135 behavior is preserved).
6896        std::fs::write(
6897            temp.path.join("config.json.bak"),
6898            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6899        )
6900        .unwrap();
6901        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6902
6903        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6904        assert_eq!(
6905            config.http_proxy, "http://from-backup",
6906            "garbage (non-object) config skips salvage and recovers from .bak"
6907        );
6908    }
6909
6910    #[test]
6911    fn quarantine_files_are_capped_to_newest_n() {
6912        let temp = TempHome::new();
6913        let config_path = temp.path.join("config.json");
6914        std::fs::write(&config_path, "{}").unwrap();
6915
6916        // Drop more quarantines than the cap; each call sleeps so nanos (the name)
6917        // and mtime (the prune sort key) are distinct.
6918        for _ in 0..(QUARANTINE_KEEP + 3) {
6919            quarantine_corrupt_config(&config_path);
6920            std::thread::sleep(std::time::Duration::from_millis(3));
6921        }
6922
6923        let count = std::fs::read_dir(&temp.path)
6924            .unwrap()
6925            .filter_map(|e| e.ok())
6926            .filter(|e| {
6927                e.file_name()
6928                    .to_string_lossy()
6929                    .starts_with("config.json.corrupted.")
6930            })
6931            .count();
6932        assert_eq!(
6933            count, QUARANTINE_KEEP,
6934            "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
6935        );
6936    }
6937
6938    #[test]
6939    fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
6940        let temp = TempHome::new();
6941        // Primary AND the freshest .bak are corrupt; an older generation is good.
6942        std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
6943        std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
6944        std::fs::write(
6945            temp.path.join("config.json.bak.1"),
6946            serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
6947        )
6948        .unwrap();
6949
6950        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6951        assert_eq!(
6952            config.http_proxy, "http://from-gen-1",
6953            "recovered from .bak.1 when both config.json and .bak are corrupt"
6954        );
6955    }
6956
6957    #[test]
6958    fn save_rotates_backup_generations() {
6959        let temp = TempHome::new();
6960        let path = temp.path.join("config.json");
6961        // v1 is the existing on-disk config.
6962        std::fs::write(
6963            &path,
6964            serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
6965        )
6966        .unwrap();
6967
6968        let mut cfg = Config::create_default();
6969        // Save 1: backs up the existing v1 -> .bak, writes v2.
6970        cfg.http_proxy = "http://proxy-v2".to_string();
6971        cfg.save_to_dir(temp.path.clone()).unwrap();
6972        // Save 2: existing (v2) is parseable -> rotate .bak(v1) -> .bak.1, .bak = v2.
6973        cfg.http_proxy = "http://proxy-v3".to_string();
6974        cfg.save_to_dir(temp.path.clone()).unwrap();
6975
6976        let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
6977        let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
6978        assert!(
6979            bak.contains("proxy-v2"),
6980            ".bak holds the previous generation (v2)"
6981        );
6982        assert!(
6983            bak1.contains("proxy-v1"),
6984            ".bak.1 holds the older rotated generation (v1)"
6985        );
6986    }
6987
6988    #[test]
6989    fn save_backs_up_existing_config() {
6990        let temp = TempHome::new();
6991        // Existing (old) config on disk.
6992        std::fs::write(
6993            temp.path.join("config.json"),
6994            serde_json::json!({ "http_proxy": "http://old" }).to_string(),
6995        )
6996        .unwrap();
6997
6998        let mut config = Config::create_default();
6999        config.http_proxy = "http://new".to_string();
7000        config
7001            .save_to_dir(temp.path.clone())
7002            .expect("save succeeds");
7003
7004        let backup =
7005            std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7006        assert!(
7007            backup.contains("http://old"),
7008            "config.json.bak holds the PREVIOUS config (last-known-good)"
7009        );
7010        let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7011        assert!(
7012            current.contains("http://new"),
7013            "config.json holds the new config"
7014        );
7015    }
7016
7017    #[test]
7018    fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7019        let temp = TempHome::new();
7020        // A good last-known-good backup...
7021        std::fs::write(
7022            temp.path.join("config.json.bak"),
7023            serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7024        )
7025        .unwrap();
7026        // ...but the on-disk config.json is corrupt (as it would be right after an
7027        // in-memory recovery, before any clean save).
7028        std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7029
7030        let mut config = Config::create_default();
7031        config.http_proxy = "http://new".to_string();
7032        config
7033            .save_to_dir(temp.path.clone())
7034            .expect("save succeeds");
7035
7036        // The good .bak must NOT have been clobbered by the corrupt config.json.
7037        let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7038        assert!(
7039            backup.contains("http://good-bak"),
7040            "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7041        );
7042    }
7043
7044    // ── config-corruption recovery confirmation gate (#153) ───────────────
7045
7046    #[test]
7047    fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7048        let temp = TempHome::new();
7049        std::fs::write(
7050            temp.path.join("config.json.bak"),
7051            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7052        )
7053        .unwrap();
7054        let corrupt_bytes = "{ not valid json ";
7055        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7056
7057        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7058        let status = config
7059            .recovery_status()
7060            .expect("a corrupt load must set a pending recovery status");
7061        assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7062        assert_eq!(
7063            status.source,
7064            ConfigRecoverySource::Backup { generation: 0 },
7065            "recovered from generation-0 (.bak)"
7066        );
7067        let quarantine_path = status
7068            .quarantine_path
7069            .as_ref()
7070            .expect("quarantine copy should have succeeded");
7071        assert_eq!(
7072            std::fs::read_to_string(quarantine_path).unwrap(),
7073            corrupt_bytes,
7074            "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7075        );
7076        assert_eq!(
7077            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7078            corrupt_bytes,
7079            "the original config.json itself is untouched by the load (only copied, not moved)"
7080        );
7081    }
7082
7083    #[test]
7084    fn recovery_status_set_from_salvage_lists_recovered_fields() {
7085        let temp = TempHome::new();
7086        temp.set_config_json(
7087            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7088        );
7089
7090        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7091        let status = config.recovery_status().expect("pending recovery");
7092        assert!(!status.confirmed);
7093        match &status.source {
7094            ConfigRecoverySource::Salvaged { fields } => {
7095                assert!(
7096                    fields.iter().any(|f| f == "http_proxy"),
7097                    "salvaged fields should list the recovered key: {fields:?}"
7098                );
7099            }
7100            other => panic!("expected Salvaged source, got {other:?}"),
7101        }
7102    }
7103
7104    #[test]
7105    fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7106        let temp = TempHome::new();
7107        // Not a JSON object at all -> salvage impossible; no .bak -> defaults.
7108        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7109
7110        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7111        let status = config.recovery_status().expect("pending recovery");
7112        assert!(!status.confirmed);
7113        assert_eq!(status.source, ConfigRecoverySource::Defaults);
7114    }
7115
7116    #[test]
7117    fn clean_load_never_sets_recovery_status() {
7118        let temp = TempHome::new();
7119        temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7120
7121        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7122        assert!(
7123            config.recovery_status().is_none(),
7124            "a config.json that parses cleanly must never carry a pending recovery status"
7125        );
7126    }
7127
7128    #[test]
7129    fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7130        let temp = TempHome::new();
7131        let corrupt_bytes = "}}} broken";
7132        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7133
7134        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7135        assert!(config.recovery_status().is_some());
7136
7137        let err = config
7138            .save_to_dir(temp.path.clone())
7139            .expect_err("save must refuse while recovery is unconfirmed");
7140        assert!(
7141            err.to_string().contains("recovered from corruption")
7142                || err.to_string().contains("confirm"),
7143            "error should explain the refused overwrite: {err}"
7144        );
7145
7146        // The corrupt original on disk must be BYTE FOR BYTE unchanged — the
7147        // refused save must not have touched it at all.
7148        assert_eq!(
7149            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7150            corrupt_bytes,
7151            "a refused save must leave the corrupt original untouched"
7152        );
7153    }
7154
7155    #[test]
7156    fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7157        let temp = TempHome::new();
7158        // Simulates a crash mid-write: valid JSON prefix, abruptly cut off.
7159        let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7160        std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7161
7162        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7163        let status = config.recovery_status().expect("pending recovery");
7164        let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7165        assert_eq!(
7166            std::fs::read_to_string(quarantine_path).unwrap(),
7167            truncated,
7168            "truncated original preserved byte for byte in quarantine"
7169        );
7170
7171        let err = config.save_to_dir(temp.path.clone());
7172        assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7173        assert_eq!(
7174            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7175            truncated,
7176            "the half-written original stays exactly as it was after a refused save"
7177        );
7178    }
7179
7180    #[test]
7181    fn confirm_recovery_allows_the_next_save() {
7182        let temp = TempHome::new();
7183        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7184
7185        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7186        assert!(config.recovery_status().is_some());
7187
7188        config.confirm_recovery();
7189        assert!(
7190            config.recovery_status().is_some_and(|s| s.confirmed),
7191            "confirm_recovery flips the flag but keeps the status around"
7192        );
7193
7194        config
7195            .save_to_dir(temp.path.clone())
7196            .expect("save must succeed once the recovery is confirmed");
7197    }
7198
7199    #[test]
7200    fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7201        let temp = TempHome::new();
7202        std::fs::write(
7203            temp.path.join("config.json"),
7204            r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7205        )
7206        .unwrap();
7207
7208        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7209        assert!(config.recovery_status().is_some());
7210        let quarantine_path = config
7211            .recovery_status()
7212            .unwrap()
7213            .quarantine_path
7214            .clone()
7215            .unwrap();
7216
7217        config
7218            .confirm_recovery_and_save_to_dir(temp.path.clone())
7219            .expect("confirm+save should succeed");
7220
7221        assert!(
7222            config.recovery_status().is_none(),
7223            "the pending flag is cleared once the recovery is confirmed and persisted"
7224        );
7225        let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7226        assert!(
7227            on_disk.contains("http://recovered"),
7228            "config.json now holds the recovered (salvaged) state"
7229        );
7230        // The quarantine copy of the original corrupt file must still exist,
7231        // untouched, even after the recovery is confirmed and persisted.
7232        assert!(
7233            quarantine_path.exists(),
7234            "the quarantined original survives confirmation — it's never deleted"
7235        );
7236    }
7237
7238    #[test]
7239    fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7240        let temp = TempHome::new();
7241        let mut config = Config::create_default();
7242        let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7243        assert!(
7244            err.is_err(),
7245            "confirming a recovery that was never pending must error, not silently succeed"
7246        );
7247    }
7248
7249    // ── connect.json split (#455) ────────────────────────────────────────
7250
7251    fn connect_platform_with_encrypted(
7252        platform_type: &str,
7253        token_encrypted: &str,
7254    ) -> ConnectPlatformConfig {
7255        ConnectPlatformConfig {
7256            id: None,
7257            project_id: None,
7258            platform_type: platform_type.to_string(),
7259            token: None,
7260            token_encrypted: Some(token_encrypted.to_string()),
7261            token_credential_ref: None,
7262            token_configured: false,
7263            app_id: None,
7264            app_secret: None,
7265            app_secret_encrypted: None,
7266            app_secret_credential_ref: None,
7267            app_secret_configured: false,
7268            domain: None,
7269            allow_from: vec!["user-1".to_string()],
7270            admin_from: Vec::new(),
7271        }
7272    }
7273
7274    fn connect_json_path(temp: &TempHome) -> PathBuf {
7275        temp.path.join("connect.json")
7276    }
7277
7278    #[test]
7279    fn save_splits_connect_into_sibling_connect_json() {
7280        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7281        let temp = TempHome::new();
7282
7283        let mut config = Config::create_default();
7284        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7285        config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7286
7287        config
7288            .save_to_dir(temp.path.clone())
7289            .expect("save succeeds");
7290
7291        let config_json: serde_json::Value =
7292            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7293                .unwrap();
7294        assert!(
7295            config_json.get("connect").is_none(),
7296            "config.json must not carry the `connect` key after a save"
7297        );
7298
7299        let connect_json: serde_json::Value =
7300            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7301                .unwrap();
7302        assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7303        assert!(
7304            connect_json["platforms"][0]["token_encrypted"]
7305                .as_str()
7306                .is_some_and(|v| !v.is_empty()),
7307            "the token is persisted in its encrypted form in connect.json"
7308        );
7309        assert!(
7310            connect_json["platforms"][0].get("token").is_none(),
7311            "the plaintext token is never persisted (skip_serializing)"
7312        );
7313    }
7314
7315    // ── stable connect.platforms id (#496) ───────────────────────────────
7316
7317    #[test]
7318    fn save_assigns_a_missing_connect_platform_id() {
7319        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7320        let temp = TempHome::new();
7321
7322        let mut config = Config::create_default();
7323        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7324        assert!(
7325            config.connect.platforms[0].id.is_none(),
7326            "precondition: the entry starts without an id"
7327        );
7328
7329        config
7330            .save_to_dir(temp.path.clone())
7331            .expect("save succeeds");
7332
7333        let connect_json: serde_json::Value =
7334            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7335                .unwrap();
7336        let persisted_id = connect_json["platforms"][0]["id"]
7337            .as_str()
7338            .expect("save_to_dir must backfill a missing id onto the persisted entry");
7339        assert!(!persisted_id.is_empty());
7340
7341        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7342        assert_eq!(
7343            reloaded.connect.platforms[0].id.as_deref(),
7344            Some(persisted_id),
7345            "the assigned id round-trips through a reload"
7346        );
7347    }
7348
7349    #[test]
7350    fn save_never_reassigns_an_existing_connect_platform_id() {
7351        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7352        let temp = TempHome::new();
7353
7354        let mut config = Config::create_default();
7355        let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7356        platform.id = Some("stable-id-123".to_string());
7357        config.connect.platforms = vec![platform];
7358
7359        config
7360            .save_to_dir(temp.path.clone())
7361            .expect("first save succeeds");
7362        // Save again (e.g. an unrelated settings change) — the id must not change.
7363        config
7364            .save_to_dir(temp.path.clone())
7365            .expect("second save succeeds");
7366
7367        let connect_json: serde_json::Value =
7368            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7369                .unwrap();
7370        assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7371    }
7372
7373    #[test]
7374    fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7375        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7376        let temp = TempHome::new();
7377
7378        let mut config = Config::create_default();
7379        config.connect.platforms = vec![
7380            connect_platform_with_encrypted("telegram", "cipher-a"),
7381            connect_platform_with_encrypted("telegram", "cipher-b"),
7382        ];
7383
7384        config
7385            .save_to_dir(temp.path.clone())
7386            .expect("save succeeds");
7387
7388        let connect_json: serde_json::Value =
7389            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7390                .unwrap();
7391        let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7392        let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7393        assert_ne!(
7394            id_a, id_b,
7395            "two entries sharing platform_type must still get distinct ids"
7396        );
7397    }
7398
7399    #[test]
7400    fn load_never_assigns_or_persists_an_id_by_itself() {
7401        let temp = TempHome::new();
7402        std::fs::write(
7403            connect_json_path(&temp),
7404            serde_json::json!({
7405                "platforms": [
7406                    { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7407                ]
7408            })
7409            .to_string(),
7410        )
7411        .unwrap();
7412        let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7413
7414        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7415
7416        assert!(
7417            config.connect.platforms[0].id.is_none(),
7418            "load alone must not backfill an id in memory"
7419        );
7420        let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7421        assert_eq!(
7422            connect_json_before, connect_json_after,
7423            "load must never rewrite connect.json on disk just to backfill an id (#493)"
7424        );
7425    }
7426
7427    #[test]
7428    fn load_merges_connect_json_into_config() {
7429        let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7430        let temp = TempHome::new();
7431        let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7432        std::fs::write(
7433            connect_json_path(&temp),
7434            serde_json::json!({
7435                "platforms": [
7436                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7437                ]
7438            })
7439            .to_string(),
7440        )
7441        .unwrap();
7442
7443        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7444        assert_eq!(config.connect.platforms.len(), 1);
7445        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7446        assert_eq!(
7447            config.connect.platforms[0].token.as_deref(),
7448            Some("connect-secret")
7449        );
7450        assert!(config.connect.platforms[0].token_encrypted.is_none());
7451    }
7452
7453    #[test]
7454    fn load_without_connect_json_yields_empty_inert_connect_config() {
7455        let temp = TempHome::new();
7456        temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7457
7458        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7459        assert!(
7460            config.connect.platforms.is_empty(),
7461            "no connect.json and no legacy key -> empty/inert connect config"
7462        );
7463        assert!(
7464            !connect_json_path(&temp).exists(),
7465            "load must not create connect.json when there is nothing to migrate"
7466        );
7467    }
7468
7469    #[test]
7470    fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7471        let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7472        let temp = TempHome::new();
7473        let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7474        let legacy_cipher_for_assert = legacy_cipher.clone();
7475        // Legacy state (#453): connect lives inline in config.json, no connect.json yet.
7476        temp.set_config_json(
7477            &serde_json::json!({
7478                "http_proxy": "http://keep-me",
7479                "connect": {
7480                    "platforms": [
7481                        { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7482                    ]
7483                }
7484            })
7485            .to_string(),
7486        );
7487
7488        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7489
7490        // In-memory: legacy value adopted.
7491        assert_eq!(config.connect.platforms.len(), 1);
7492        assert_eq!(
7493            config.connect.platforms[0].token.as_deref(),
7494            Some("legacy-connect-secret")
7495        );
7496        // An unrelated field from the same load survives the migration rewrite.
7497        assert_eq!(config.http_proxy, "http://keep-me");
7498
7499        // On disk: connect.json was created...
7500        assert!(
7501            connect_json_path(&temp).exists(),
7502            "migration proactively creates connect.json"
7503        );
7504        let connect_json: serde_json::Value =
7505            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7506                .unwrap();
7507        assert_eq!(
7508            connect_json["platforms"][0]["token_encrypted"],
7509            legacy_cipher_for_assert
7510        );
7511
7512        // ...and config.json was rewritten without the `connect` key.
7513        let config_json: serde_json::Value =
7514            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7515                .unwrap();
7516        assert!(
7517            config_json.get("connect").is_none(),
7518            "config.json is rewritten without the legacy `connect` key"
7519        );
7520    }
7521
7522    /// #457: the legacy-key migration must be a NARROW write (strip `connect`
7523    /// from config.json + write connect.json) — not the full `save_to_dir`,
7524    /// which would re-encrypt every OTHER secret in config.json and rotate a
7525    /// `config.json.bak` generation as a load-time side effect. This matters
7526    /// most for a purely READ-ONLY command (e.g. `bamboo config get`) run on a
7527    /// machine that still has the legacy `connect` key: it must not silently
7528    /// rewrite/re-encrypt unrelated secrets or spin up a backup.
7529    #[test]
7530    fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
7531        let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
7532        let temp = TempHome::new();
7533
7534        let original_api_key_encrypted =
7535            crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
7536        temp.set_config_json(
7537            &serde_json::json!({
7538                "providers": {
7539                    "openai": {
7540                        "api_key_encrypted": original_api_key_encrypted,
7541                    }
7542                },
7543                "connect": {
7544                    "platforms": [
7545                        { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
7546                    ]
7547                }
7548            })
7549            .to_string(),
7550        );
7551
7552        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7553        assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
7554
7555        // No `config.json.bak` — the narrow write does not rotate backups the
7556        // way a full `save_to_dir` would.
7557        assert!(
7558            !temp.path.join("config.json.bak").exists(),
7559            "a read-only load migrating a legacy `connect` key must not rotate \
7560             config.json backups"
7561        );
7562
7563        // The unrelated provider secret's ciphertext is byte-for-byte
7564        // unchanged — proof it was never decrypted+re-encrypted (encryption
7565        // uses a random nonce per call, so any re-encryption would change the
7566        // bytes even for the same plaintext).
7567        let config_json: serde_json::Value =
7568            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7569                .unwrap();
7570        assert_eq!(
7571            config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
7572            "an unrelated secret's ciphertext must not be touched by the connect \
7573             migration's narrow write"
7574        );
7575        assert!(
7576            config_json.get("connect").is_none(),
7577            "config.json is still rewritten without the legacy `connect` key"
7578        );
7579    }
7580
7581    #[test]
7582    fn both_files_present_connect_json_wins() {
7583        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
7584        let temp = TempHome::new();
7585        let stale = crate::encryption::encrypt("stale-secret").unwrap();
7586        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7587        temp.set_config_json(
7588            &serde_json::json!({
7589                "connect": {
7590                    "platforms": [
7591                        { "type": "telegram", "token_encrypted": stale }
7592                    ]
7593                }
7594            })
7595            .to_string(),
7596        );
7597        std::fs::write(
7598            connect_json_path(&temp),
7599            serde_json::json!({
7600                "platforms": [
7601                    { "type": "telegram", "token_encrypted": authoritative }
7602                ]
7603            })
7604            .to_string(),
7605        )
7606        .unwrap();
7607
7608        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7609        assert_eq!(
7610            config.connect.platforms[0].token.as_deref(),
7611            Some("authoritative-secret"),
7612            "connect.json wins over a stale legacy config.json key"
7613        );
7614    }
7615
7616    /// #457: when both files are present, the superseded `connect` key in
7617    /// config.json must be stripped PROACTIVELY on load — not left to linger
7618    /// until the next natural save, which spreads token ciphertext across two
7619    /// files for longer than necessary.
7620    #[test]
7621    fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
7622        let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
7623        let temp = TempHome::new();
7624        let stale = crate::encryption::encrypt("stale-secret").unwrap();
7625        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7626        temp.set_config_json(
7627            &serde_json::json!({
7628                "http_proxy": "http://keep-me",
7629                "connect": {
7630                    "platforms": [
7631                        { "type": "telegram", "token_encrypted": stale }
7632                    ]
7633                }
7634            })
7635            .to_string(),
7636        );
7637        std::fs::write(
7638            connect_json_path(&temp),
7639            serde_json::json!({
7640                "platforms": [
7641                    { "type": "telegram", "token_encrypted": authoritative }
7642                ]
7643            })
7644            .to_string(),
7645        )
7646        .unwrap();
7647
7648        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7649        assert_eq!(
7650            config.connect.platforms[0].token.as_deref(),
7651            Some("authoritative-secret")
7652        );
7653        // Unrelated field survives the strip.
7654        assert_eq!(config.http_proxy, "http://keep-me");
7655
7656        let config_json: serde_json::Value =
7657            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7658                .unwrap();
7659        assert!(
7660            config_json.get("connect").is_none(),
7661            "the stale legacy `connect` key must be stripped from config.json \
7662             immediately on load, not left for the next natural save"
7663        );
7664    }
7665
7666    /// #468 (follow-up to #457): a `.bak` generation that predates the #455
7667    /// split can still carry the legacy embedded `connect` sub-tree even
7668    /// after the LIVE config.json has long since been migrated (a clean
7669    /// config.json here, with no `connect` key at all, proves the sweep does
7670    /// not depend on the current-load migration path having just fired).
7671    /// Only the tainted generation is rewritten; every other key in it
7672    /// survives, and the rewrite strips exactly the `connect` key.
7673    #[test]
7674    fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
7675        let temp = TempHome::new();
7676        temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
7677        std::fs::write(
7678            temp.path.join("config.json.bak"),
7679            serde_json::json!({
7680                "http_proxy": "http://old",
7681                "connect": {
7682                    "platforms": [
7683                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
7684                    ]
7685                }
7686            })
7687            .to_string(),
7688        )
7689        .unwrap();
7690
7691        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7692        // The live config is unaffected — connect.json never existed and
7693        // config.json never had the key, so in-memory connect stays empty.
7694        assert!(config.connect.platforms.is_empty());
7695
7696        let bak: serde_json::Value = serde_json::from_str(
7697            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7698        )
7699        .unwrap();
7700        assert!(
7701            bak.get("connect").is_none(),
7702            "the legacy `connect` key must be stripped from the tainted .bak generation"
7703        );
7704        assert_eq!(
7705            bak["http_proxy"], "http://old",
7706            "every other key in the .bak generation survives the scrub byte-for-byte in content"
7707        );
7708    }
7709
7710    /// The sweep must touch EVERY rotated generation that carries the legacy
7711    /// key, not just `.bak` — an upgraded instance can have the taint several
7712    /// generations deep depending on how many saves happened since #455/#457
7713    /// shipped but before this fix.
7714    #[test]
7715    fn scrub_reaches_all_rotated_generations() {
7716        let temp = TempHome::new();
7717        temp.set_config_json(&serde_json::json!({}).to_string());
7718        for (gen_suffix, cipher) in [
7719            ("config.json.bak", "cipher-gen0"),
7720            ("config.json.bak.1", "cipher-gen1"),
7721            ("config.json.bak.2", "cipher-gen2"),
7722        ] {
7723            std::fs::write(
7724                temp.path.join(gen_suffix),
7725                serde_json::json!({
7726                    "connect": {
7727                        "platforms": [
7728                            { "type": "telegram", "token_encrypted": cipher }
7729                        ]
7730                    }
7731                })
7732                .to_string(),
7733            )
7734            .unwrap();
7735        }
7736
7737        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7738
7739        for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
7740            let value: serde_json::Value =
7741                serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
7742                    .unwrap();
7743            assert!(
7744                value.get("connect").is_none(),
7745                "{gen_suffix} must have its legacy `connect` key stripped"
7746            );
7747        }
7748    }
7749
7750    /// A `.bak` generation with NO legacy `connect` key must be left
7751    /// completely untouched by the sweep — not even a byte-identical
7752    /// rewrite — preserving the file's bytes/mtime exactly. This is the
7753    /// overwhelming common case (any backup created after #455/#457 shipped)
7754    /// and the whole point of the surgical, only-touch-what's-tainted
7755    /// approach: `.bak` files are the user's recovery net (#493) and
7756    /// shouldn't be churned by an unrelated sweep.
7757    #[test]
7758    fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
7759        let temp = TempHome::new();
7760        temp.set_config_json(&serde_json::json!({}).to_string());
7761        let bak_path = temp.path.join("config.json.bak");
7762        std::fs::write(
7763            &bak_path,
7764            serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
7765        )
7766        .unwrap();
7767
7768        let before_bytes = std::fs::read(&bak_path).unwrap();
7769        let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7770
7771        // A tiny sleep would make an mtime-changed assertion more robust, but
7772        // even without one, a same-mtime filesystem is the STRONGER
7773        // guarantee of "no write happened" — good enough on its own.
7774        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7775
7776        let after_bytes = std::fs::read(&bak_path).unwrap();
7777        let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7778        assert_eq!(
7779            before_bytes, after_bytes,
7780            "a .bak generation without a legacy `connect` key must not be rewritten at all"
7781        );
7782        assert_eq!(
7783            before_mtime, after_mtime,
7784            "no write means no mtime change either"
7785        );
7786    }
7787
7788    /// An unparsable `.bak` generation (corrupt/foreign content) must be
7789    /// skipped, not deleted and not guessed at — it's left exactly as found
7790    /// so an operator can inspect it by hand, matching the same fail-safe
7791    /// posture as the rest of the backup/quarantine machinery.
7792    #[test]
7793    fn scrub_skips_unparsable_backup_without_deleting_it() {
7794        let temp = TempHome::new();
7795        temp.set_config_json(&serde_json::json!({}).to_string());
7796        std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
7797
7798        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7799
7800        let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7801        assert_eq!(
7802            content, "{ not valid json",
7803            "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
7804        );
7805    }
7806
7807    /// A missing generation (e.g. only `.bak` exists, no `.bak.1`/`.bak.2`
7808    /// yet) must not trip an error — it's the common case for a young
7809    /// install and the sweep should just skip straight past it.
7810    #[test]
7811    fn scrub_tolerates_missing_generations() {
7812        let temp = TempHome::new();
7813        temp.set_config_json(&serde_json::json!({}).to_string());
7814        // No .bak files at all.
7815        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7816        assert!(config.connect.platforms.is_empty());
7817        assert!(!temp.path.join("config.json.bak").exists());
7818    }
7819
7820    /// The scrub sweep must not interfere with normal backup rotation on
7821    /// subsequent saves — rotation keeps working exactly as before.
7822    #[test]
7823    fn scrub_does_not_break_backup_rotation() {
7824        let temp = TempHome::new();
7825        std::fs::write(
7826            temp.path.join("config.json"),
7827            serde_json::json!({
7828                "http_proxy": "http://proxy-v1",
7829                "connect": {
7830                    "platforms": [
7831                        { "type": "telegram", "token_encrypted": "legacy-cipher" }
7832                    ]
7833                }
7834            })
7835            .to_string(),
7836        )
7837        .unwrap();
7838        std::fs::write(
7839            temp.path.join("config.json.bak"),
7840            serde_json::json!({
7841                "http_proxy": "http://proxy-v0",
7842                "connect": {
7843                    "platforms": [
7844                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
7845                    ]
7846                }
7847            })
7848            .to_string(),
7849        )
7850        .unwrap();
7851
7852        // Load triggers: migration of the live legacy key + the .bak sweep.
7853        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7854        let bak: serde_json::Value = serde_json::from_str(
7855            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7856        )
7857        .unwrap();
7858        assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
7859
7860        // Rotation still works on a subsequent save: v_current -> .bak,
7861        // .bak(old) -> .bak.1.
7862        config.http_proxy = "http://proxy-v2".to_string();
7863        config.save_to_dir(temp.path.clone()).unwrap();
7864
7865        let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7866        assert!(
7867            new_bak.contains("proxy-v1"),
7868            ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
7869        );
7870        let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7871        assert!(
7872            new_bak1.contains("proxy-v0"),
7873            ".bak.1 holds the scrubbed older generation after rotation"
7874        );
7875        assert!(
7876            !new_bak1.contains("legacy-bak-cipher"),
7877            "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
7878             stripped secret"
7879        );
7880    }
7881
7882    #[test]
7883    fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
7884        let temp = TempHome::new();
7885        temp.set_config_json("{}");
7886        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
7887
7888        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7889        assert!(
7890            config.connect.platforms.is_empty(),
7891            "corrupt connect.json fails SAFE to an empty/inert connect config"
7892        );
7893
7894        let backup = connect_json_path(&temp).with_extension("json.bak");
7895        assert!(
7896            backup.exists(),
7897            "the corrupt connect.json is quarantined to connect.json.bak"
7898        );
7899        assert!(
7900            std::fs::read_to_string(backup)
7901                .unwrap()
7902                .contains("not valid json"),
7903            "the quarantined copy holds the bad content"
7904        );
7905        // #457: quarantine MOVES the corrupt file rather than copying it, so
7906        // the data dir doesn't end up with two copies of the same corrupt
7907        // content (the live `connect.json` and its `.bak`) sitting side by
7908        // side, which reads as confusing/ambiguous mid-incident.
7909        assert!(
7910            !connect_json_path(&temp).exists(),
7911            "quarantine must MOVE the corrupt connect.json (not copy it) — no \
7912             connect.json should remain after quarantine"
7913        );
7914    }
7915
7916    #[test]
7917    fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
7918        let temp = TempHome::new();
7919        // A legacy inline `connect` key is present too — it must NOT be used as a
7920        // fallback when connect.json is corrupt (security-sensitive: fail safe).
7921        temp.set_config_json(
7922            &serde_json::json!({
7923                "connect": {
7924                    "platforms": [
7925                        { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
7926                    ]
7927                }
7928            })
7929            .to_string(),
7930        );
7931        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
7932
7933        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7934        assert!(
7935            config.connect.platforms.is_empty(),
7936            "corrupt connect.json must not fall back to the legacy config.json copy"
7937        );
7938    }
7939
7940    #[test]
7941    fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
7942        let temp = TempHome::new();
7943        let config = Config::create_default();
7944        assert!(config.connect.platforms.is_empty());
7945
7946        config
7947            .save_to_dir(temp.path.clone())
7948            .expect("save succeeds");
7949
7950        assert!(
7951            !connect_json_path(&temp).exists(),
7952            "an empty connect config with no pre-existing file must not create one"
7953        );
7954    }
7955
7956    #[test]
7957    fn connect_json_backed_up_before_overwrite() {
7958        let temp = TempHome::new();
7959        std::fs::write(
7960            connect_json_path(&temp),
7961            serde_json::json!({
7962                "platforms": [
7963                    { "type": "telegram", "token_encrypted": "old-cipher" }
7964                ]
7965            })
7966            .to_string(),
7967        )
7968        .unwrap();
7969
7970        let mut config = Config::create_default();
7971        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
7972        config
7973            .save_to_dir(temp.path.clone())
7974            .expect("save succeeds");
7975
7976        let backup = connect_json_path(&temp).with_extension("json.bak");
7977        assert!(
7978            std::fs::read_to_string(backup)
7979                .unwrap()
7980                .contains("old-cipher"),
7981            "the previous connect.json is preserved as connect.json.bak before the overwrite"
7982        );
7983        let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7984        assert!(current.contains("new-cipher"));
7985    }
7986
7987    // ── Feishu adapter config fields (epic #447 phase 3, §2a) ───────────
7988
7989    #[test]
7990    fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
7991        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7992        let temp = TempHome::new();
7993
7994        let mut config = Config::create_default();
7995        config.connect.platforms = vec![ConnectPlatformConfig {
7996            id: None,
7997            project_id: None,
7998            platform_type: "feishu".to_string(),
7999            token: None,
8000            token_encrypted: None,
8001            token_credential_ref: None,
8002            token_configured: false,
8003            app_id: Some("cli_real_app_id".to_string()),
8004            app_secret: Some("plain-app-secret".to_string()),
8005            app_secret_encrypted: None,
8006            app_secret_credential_ref: None,
8007            app_secret_configured: false,
8008            domain: Some("lark".to_string()),
8009            allow_from: vec!["ou_1".to_string()],
8010            admin_from: Vec::new(),
8011        }];
8012
8013        config
8014            .save_to_dir(temp.path.clone())
8015            .expect("save succeeds");
8016
8017        let connect_json: serde_json::Value =
8018            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8019                .unwrap();
8020        assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8021        assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8022        assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8023        assert!(
8024            connect_json["platforms"][0]["app_secret_encrypted"]
8025                .as_str()
8026                .is_some_and(|v| !v.is_empty()),
8027            "app_secret is persisted in its encrypted form in connect.json"
8028        );
8029        assert!(
8030            connect_json["platforms"][0].get("app_secret").is_none(),
8031            "the plaintext app_secret is never persisted (skip_serializing)"
8032        );
8033    }
8034
8035    #[test]
8036    fn load_hydrates_feishu_app_secret_from_encrypted() {
8037        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8038        let temp = TempHome::new();
8039
8040        let mut config = Config::create_default();
8041        config.connect.platforms = vec![ConnectPlatformConfig {
8042            id: None,
8043            project_id: None,
8044            platform_type: "feishu".to_string(),
8045            token: None,
8046            token_encrypted: None,
8047            token_credential_ref: None,
8048            token_configured: false,
8049            app_id: Some("cli_real_app_id".to_string()),
8050            app_secret: Some("plain-app-secret".to_string()),
8051            app_secret_encrypted: None,
8052            app_secret_credential_ref: None,
8053            app_secret_configured: false,
8054            domain: Some("lark".to_string()),
8055            allow_from: vec!["ou_1".to_string()],
8056            admin_from: Vec::new(),
8057        }];
8058        config
8059            .save_to_dir(temp.path.clone())
8060            .expect("save succeeds");
8061
8062        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8063        assert_eq!(reloaded.connect.platforms.len(), 1);
8064        assert_eq!(
8065            reloaded.connect.platforms[0].app_secret.as_deref(),
8066            Some("plain-app-secret"),
8067            "reload hydrates app_secret from app_secret_encrypted"
8068        );
8069        assert_eq!(
8070            reloaded.connect.platforms[0].app_id.as_deref(),
8071            Some("cli_real_app_id")
8072        );
8073        assert_eq!(
8074            reloaded.connect.platforms[0].domain.as_deref(),
8075            Some("lark")
8076        );
8077    }
8078
8079    #[test]
8080    fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8081        let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8082        let temp = TempHome::new();
8083        let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8084        std::fs::write(
8085            connect_json_path(&temp),
8086            serde_json::json!({
8087                "platforms": [
8088                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8089                ]
8090            })
8091            .to_string(),
8092        )
8093        .unwrap();
8094
8095        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8096
8097        assert_eq!(config.connect.platforms.len(), 1);
8098        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8099        assert_eq!(
8100            config.connect.platforms[0].token.as_deref(),
8101            Some("legacy-telegram-secret")
8102        );
8103        assert_eq!(
8104            config.connect.platforms[0].app_id, None,
8105            "a legacy entry with no Feishu fields deserializes them as None"
8106        );
8107        assert_eq!(config.connect.platforms[0].app_secret, None);
8108        assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8109        assert_eq!(config.connect.platforms[0].domain, None);
8110    }
8111
8112    #[test]
8113    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8114        let _lock = env_lock_acquire();
8115        let temp_home = TempHome::new();
8116        temp_home.set_config_json(
8117            r#"{
8118  "provider": "openai",
8119  "providers": {
8120    "openai": {
8121      "api_key": "sk-test",
8122      "model": "gpt-4o"
8123    }
8124  }
8125}"#,
8126        );
8127
8128        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8129        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8130
8131        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8132
8133        assert_eq!(
8134            config
8135                .providers
8136                .openai
8137                .as_ref()
8138                .and_then(|c| c.model.as_deref()),
8139            Some("gpt-4o")
8140        );
8141        assert!(
8142            config.http_proxy.is_empty(),
8143            "config should keep http_proxy empty when field is omitted"
8144        );
8145        assert!(
8146            config.https_proxy.is_empty(),
8147            "config should keep https_proxy empty when field is omitted"
8148        );
8149    }
8150
8151    #[test]
8152    fn get_memory_background_model_prefers_memory_specific_override() {
8153        let mut config = Config::default();
8154        config.features.provider_model_ref = false;
8155        config.provider = "openai".to_string();
8156        config.providers.openai = Some(OpenAIConfig {
8157            api_key: "test".to_string(),
8158            api_key_encrypted: None,
8159            credential_ref: None,
8160            base_url: None,
8161            model: Some("gpt-main".to_string()),
8162            fast_model: Some("gpt-fast".to_string()),
8163            vision_model: None,
8164            reasoning_effort: None,
8165            responses_only_models: vec![],
8166            request_overrides: None,
8167            extra: BTreeMap::new(),
8168            api_key_from_env: false,
8169        });
8170        config.memory.0 = Some(MemoryConfig {
8171            background_model: Some("memory-fast".to_string()),
8172            ..MemoryConfig::default()
8173        });
8174
8175        assert_eq!(
8176            config.get_memory_background_model().as_deref(),
8177            Some("memory-fast")
8178        );
8179    }
8180
8181    #[test]
8182    fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8183        // #373: the settings-PATCH serde round-trip drops every provider's
8184        // skip_serializing api_key; an env-sourced key (no ciphertext) can't be
8185        // re-hydrated, so it must be copied back from the live `current` config —
8186        // but an explicitly re-set key and non-env keys must NOT be touched.
8187        let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8188            api_key: api_key.to_string(),
8189            api_key_encrypted: None,
8190            credential_ref: None,
8191            base_url: None,
8192            model: None,
8193            fast_model: None,
8194            vision_model: None,
8195            reasoning_effort: None,
8196            responses_only_models: vec![],
8197            request_overrides: None,
8198            extra: BTreeMap::new(),
8199            api_key_from_env: from_env,
8200        };
8201
8202        // Env-sourced key dropped by the round-trip → restored.
8203        let mut current = Config::default();
8204        current.providers.openai = Some(openai("sk-env", true));
8205        let mut merged = Config::default();
8206        merged.providers.openai = Some(openai("", false)); // post-round-trip
8207        merged.preserve_env_sourced_provider_keys(&current);
8208        let got = merged.providers.openai.as_ref().unwrap();
8209        assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8210        assert!(got.api_key_from_env, "env flag restored");
8211
8212        // A key explicitly re-set by the patch is NOT overridden.
8213        let mut merged = Config::default();
8214        merged.providers.openai = Some(openai("sk-explicit", false));
8215        merged.preserve_env_sourced_provider_keys(&current);
8216        assert_eq!(
8217            merged.providers.openai.as_ref().unwrap().api_key,
8218            "sk-explicit",
8219            "explicit patch key must win"
8220        );
8221
8222        // A non-env key in current is NOT restored here (that's ciphertext hydration's job).
8223        let mut current_plain = Config::default();
8224        current_plain.providers.openai = Some(openai("sk-plain", false));
8225        let mut merged = Config::default();
8226        merged.providers.openai = Some(openai("", false));
8227        merged.preserve_env_sourced_provider_keys(&current_plain);
8228        assert!(
8229            merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8230            "non-env key must not be restored by this path"
8231        );
8232    }
8233
8234    #[test]
8235    fn refresh_preserves_ciphertext_when_plaintext_empty() {
8236        // #268: a provider whose stored ciphertext failed to decrypt at hydration
8237        // has an empty in-memory api_key. An unrelated later save must NOT null its
8238        // ciphertext — that would permanently drop a key the user never touched.
8239        let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8240            api_key: api_key.to_string(),
8241            api_key_encrypted: enc.map(str::to_string),
8242            credential_ref: None,
8243            base_url: None,
8244            model: None,
8245            fast_model: None,
8246            vision_model: None,
8247            reasoning_effort: None,
8248            responses_only_models: vec![],
8249            request_overrides: None,
8250            extra: BTreeMap::new(),
8251            api_key_from_env: false,
8252        };
8253
8254        // Empty plaintext + existing ciphertext → ciphertext preserved (the bug).
8255        let mut config = Config::default();
8256        config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8257        config
8258            .refresh_provider_api_keys_encrypted()
8259            .expect("refresh");
8260        assert_eq!(
8261            config
8262                .providers
8263                .openai
8264                .as_ref()
8265                .unwrap()
8266                .api_key_encrypted
8267                .as_deref(),
8268            Some("preexisting-ciphertext"),
8269            "existing ciphertext must be preserved when plaintext is empty"
8270        );
8271
8272        // Empty plaintext + no ciphertext → stays None (nothing to preserve).
8273        let mut config = Config::default();
8274        config.providers.openai = Some(openai("", None));
8275        config
8276            .refresh_provider_api_keys_encrypted()
8277            .expect("refresh");
8278        assert!(
8279            config
8280                .providers
8281                .openai
8282                .as_ref()
8283                .unwrap()
8284                .api_key_encrypted
8285                .is_none(),
8286            "no key + no ciphertext should stay None"
8287        );
8288
8289        // Non-empty plaintext → (re)encrypted to a fresh, non-empty ciphertext.
8290        let mut config = Config::default();
8291        config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8292        config
8293            .refresh_provider_api_keys_encrypted()
8294            .expect("refresh");
8295        let enc = config
8296            .providers
8297            .openai
8298            .as_ref()
8299            .unwrap()
8300            .api_key_encrypted
8301            .clone()
8302            .expect("ciphertext present");
8303        assert!(
8304            !enc.is_empty() && enc != "stale-ciphertext",
8305            "plaintext re-encrypted"
8306        );
8307    }
8308
8309    #[test]
8310    fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8311        // #516: `save_to_dir` refreshes ciphertext only on its save-time clone,
8312        // so a provider instance created over HTTP stays plaintext-only in the
8313        // live config. Serializing that live config (as the settings-PATCH
8314        // merge does) drops the `skip_serializing` plaintext and the key is
8315        // gone. `refresh_encrypted_secrets` on the live config closes the gap.
8316        let mut config = Config::default();
8317        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8318            "provider_type": "openai",
8319            "api_key": "sk-instance-live",
8320        }))
8321        .expect("valid instance");
8322        config
8323            .provider_instances
8324            .insert("work".to_string(), instance);
8325
8326        config.refresh_encrypted_secrets().expect("refresh");
8327        assert!(
8328            config.provider_instances["work"]
8329                .api_key_encrypted
8330                .is_some(),
8331            "live config must hold ciphertext after refresh"
8332        );
8333
8334        // The build_merged_config-style round-trip.
8335        let value = serde_json::to_value(&config).expect("serialize");
8336        let mut back: Config = serde_json::from_value(value).expect("deserialize");
8337        assert!(
8338            back.provider_instances["work"].api_key.is_empty(),
8339            "plaintext is skip_serializing"
8340        );
8341        back.hydrate_provider_instance_api_keys_from_encrypted();
8342        assert_eq!(
8343            back.provider_instances["work"].api_key, "sk-instance-live",
8344            "key must be recoverable from the round-tripped ciphertext"
8345        );
8346    }
8347
8348    #[test]
8349    fn get_memory_background_model_falls_back_to_provider_fast_model() {
8350        let mut config = Config::default();
8351        config.features.provider_model_ref = false;
8352        config.provider = "openai".to_string();
8353        config.providers.openai = Some(OpenAIConfig {
8354            api_key: "test".to_string(),
8355            api_key_encrypted: None,
8356            credential_ref: None,
8357            base_url: None,
8358            model: Some("gpt-main".to_string()),
8359            fast_model: Some("gpt-fast".to_string()),
8360            vision_model: None,
8361            reasoning_effort: None,
8362            responses_only_models: vec![],
8363            request_overrides: None,
8364            extra: BTreeMap::new(),
8365            api_key_from_env: false,
8366        });
8367
8368        assert_eq!(
8369            config.get_memory_background_model().as_deref(),
8370            Some("gpt-fast")
8371        );
8372    }
8373
8374    #[test]
8375    fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8376        let mut config = Config::default();
8377        config.features.provider_model_ref = false;
8378        config.provider = "openai".to_string();
8379        config.providers.openai = Some(OpenAIConfig {
8380            api_key: "sk-stale".to_string(),
8381            model: Some("legacy-main".to_string()),
8382            fast_model: Some("legacy-fast".to_string()),
8383            vision_model: Some("legacy-vision".to_string()),
8384            reasoning_effort: Some(ReasoningEffort::Low),
8385            ..OpenAIConfig::default()
8386        });
8387        let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8388            "provider_type": "openai",
8389            "model": "instance-main",
8390            "fast_model": "instance-fast",
8391            "vision_model": "instance-vision",
8392            "reasoning_effort": "high",
8393            "enabled": true
8394        }))
8395        .unwrap();
8396        instance.api_key = "sk-instance".to_string();
8397        config
8398            .provider_instances
8399            .insert("work".to_string(), instance);
8400        config.default_provider_instance = Some("work".to_string());
8401
8402        assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8403        assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8404        assert_eq!(
8405            config.get_memory_background_model().as_deref(),
8406            Some("instance-fast")
8407        );
8408        assert_eq!(
8409            config.get_task_summary_model().as_deref(),
8410            Some("instance-fast")
8411        );
8412        assert_eq!(
8413            config.get_vision_model().as_deref(),
8414            Some("instance-vision")
8415        );
8416        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8417    }
8418
8419    #[test]
8420    fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8421        let _provider =
8422            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8423        let _openai_key = crate::test_support::override_runtime_env_var(
8424            "BAMBOO_OPENAI_API_KEY",
8425            Some("sk-runtime-env-instance"),
8426        );
8427        let _anthropic_key =
8428            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8429        let _gemini_key =
8430            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8431
8432        let mut config = Config::default();
8433        *config.providers_mut() = ProviderConfigs::default();
8434        for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8435            let mut extra = serde_json::Map::new();
8436            if from_environment {
8437                extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8438            }
8439            extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8440            extra.insert("enabled".to_string(), serde_json::json!(true));
8441            config.provider_instances.insert(
8442                id.to_string(),
8443                serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8444            );
8445        }
8446
8447        config.apply_runtime_env_overrides();
8448
8449        assert_eq!(
8450            config.default_provider_instance.as_deref(),
8451            Some("a-marked"),
8452            "type override selects the lexicographically first enabled instance"
8453        );
8454        assert_eq!(
8455            config.provider_instances["a-marked"].api_key,
8456            "sk-runtime-env-instance"
8457        );
8458        assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
8459        assert!(
8460            config.providers().openai.is_none(),
8461            "instance-mode env hydration must not recreate a legacy alias"
8462        );
8463
8464        config.refresh_encrypted_secrets().unwrap();
8465        let serialized = serde_json::to_string(&config).unwrap();
8466        assert!(!serialized.contains("sk-runtime-env-instance"));
8467        assert!(
8468            config.provider_instances["a-marked"]
8469                .api_key_encrypted
8470                .is_none(),
8471            "runtime env plaintext must not be encrypted into ordinary config"
8472        );
8473    }
8474
8475    #[test]
8476    fn runtime_provider_override_prefers_exact_instance_id() {
8477        let _provider =
8478            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
8479        let _openai_key =
8480            crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
8481        let _anthropic_key =
8482            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8483        let _gemini_key =
8484            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8485        let mut config = Config::default();
8486        for id in ["work", "personal"] {
8487            config.provider_instances.insert(
8488                id.to_string(),
8489                serde_json::from_value(serde_json::json!({
8490                    "provider_type": "openai",
8491                    "enabled": true
8492                }))
8493                .unwrap(),
8494            );
8495        }
8496
8497        config.apply_runtime_env_overrides();
8498        assert_eq!(
8499            config.default_provider_instance.as_deref(),
8500            Some("personal")
8501        );
8502    }
8503
8504    #[test]
8505    fn get_memory_background_model_does_not_fall_back_to_main_model() {
8506        let mut config = Config::default();
8507        config.features.provider_model_ref = false;
8508        config.provider = "openai".to_string();
8509        config.providers.openai = Some(OpenAIConfig {
8510            api_key: "test".to_string(),
8511            api_key_encrypted: None,
8512            credential_ref: None,
8513            base_url: None,
8514            model: Some("gpt-main".to_string()),
8515            fast_model: None,
8516            vision_model: None,
8517            reasoning_effort: None,
8518            responses_only_models: vec![],
8519            request_overrides: None,
8520            extra: BTreeMap::new(),
8521            api_key_from_env: false,
8522        });
8523
8524        assert!(config.get_memory_background_model().is_none());
8525    }
8526
8527    #[test]
8528    fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
8529        let legacy = serde_json::json!({
8530            "memory": MemoryConfig {
8531                background_model: Some("dream-fast".to_string()),
8532                summary_target_ratio: 0.20,
8533                summary_safe_window_percent: 80,
8534                auto_dream_enabled: true,
8535                auto_dream_interval_secs: 900,
8536                project_prompt_injection: false,
8537                relevant_recall: false,
8538                relevant_recall_rerank: true,
8539                project_first_dream: false,
8540                ledger_agenda_injection: false,
8541                ledger_gardener_enabled: false,
8542                ledger_gardener_interval_secs: 7_200,
8543                ledger_distillation_enabled: false,
8544                dream_refine_mode: true,
8545                gardener_enabled: true,
8546                gardener_interval_secs: 3_600,
8547                gardener_volume_trigger: 40,
8548                gardener_max_splits_per_run: 4,
8549                gardener_min_sections: 7,
8550                dedup_gardener_enabled: true,
8551                dedup_gardener_min_score: 0.7,
8552                dedup_gardener_max_merges_per_run: 3,
8553                memory_active_capacity: 500,
8554                capacity_max_archivals_per_run: 10,
8555                granularity_freshness_gardener_enabled: false,
8556            }
8557        });
8558        let config: Config = serde_json::from_value(legacy).unwrap();
8559
8560        let serialized = serde_json::to_value(&config).expect("config should serialize");
8561        assert!(serialized.get("memory").is_some());
8562        let round_tripped: Config = serde_json::from_value(serialized).unwrap();
8563        assert!(round_tripped
8564            .memory()
8565            .as_ref()
8566            .is_some_and(|memory| memory.dream_refine_mode));
8567        let memory = config.memory.as_ref().expect("memory config should exist");
8568        assert!(memory.auto_dream_enabled);
8569        assert!(!memory.project_prompt_injection);
8570        assert!(!memory.relevant_recall);
8571        assert!(memory.relevant_recall_rerank);
8572        assert!(!memory.project_first_dream);
8573        assert!(memory.dream_refine_mode);
8574        assert!(memory.gardener_enabled);
8575        assert_eq!(memory.gardener_interval_secs, 3_600);
8576        assert_eq!(memory.gardener_volume_trigger, 40);
8577        assert_eq!(memory.gardener_max_splits_per_run, 4);
8578        assert_eq!(memory.gardener_min_sections, 7);
8579        assert!(memory.dedup_gardener_enabled);
8580        assert_eq!(memory.dedup_gardener_min_score, 0.7);
8581        assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
8582        assert_eq!(memory.memory_active_capacity, 500);
8583        assert_eq!(memory.capacity_max_archivals_per_run, 10);
8584        assert!(!memory.granularity_freshness_gardener_enabled);
8585    }
8586
8587    /// L5: capacity is OFF by default (0 = unbounded) — an opt-in feature.
8588    #[test]
8589    fn memory_active_capacity_defaults_off() {
8590        assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
8591        assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
8592        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8593        let memory = parsed.memory.as_ref().unwrap();
8594        assert_eq!(memory.memory_active_capacity, 0);
8595        assert_eq!(
8596            memory.capacity_max_archivals_per_run, 50,
8597            "omitted field takes the serde default fn"
8598        );
8599    }
8600
8601    #[test]
8602    fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
8603        let defaults = MemoryConfig::default();
8604        assert_eq!(defaults.summary_target_ratio, 0.20);
8605        assert_eq!(defaults.summary_safe_window_percent, 80);
8606
8607        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8608        let memory = parsed.memory.as_ref().expect("memory present");
8609        assert_eq!(memory.summary_target_ratio, 0.20);
8610        assert_eq!(memory.summary_safe_window_percent, 80);
8611    }
8612
8613    /// L4: the maintenance integrators are ON by default — both via
8614    /// `MemoryConfig::default()` AND when a config file omits the flags entirely
8615    /// (serde `default = fn`, not the bare `#[serde(default)]` = `false`).
8616    #[test]
8617    fn memory_maintenance_integrators_default_on() {
8618        let defaults = MemoryConfig::default();
8619        assert!(defaults.auto_dream_enabled);
8620        assert!(defaults.gardener_enabled);
8621        assert!(defaults.dedup_gardener_enabled);
8622        assert_eq!(defaults.gardener_volume_trigger, 25);
8623
8624        // A config that mentions `memory` but omits the flags must still be ON.
8625        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8626        let memory = parsed.memory.as_ref().expect("memory present");
8627        assert!(
8628            memory.auto_dream_enabled,
8629            "auto_dream on when field omitted"
8630        );
8631        assert!(memory.gardener_enabled, "gardener on when field omitted");
8632        assert!(
8633            memory.dedup_gardener_enabled,
8634            "dedup gardener on when field omitted"
8635        );
8636        // An explicit opt-out is still honored.
8637        let opted_out: Config =
8638            serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
8639        assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
8640    }
8641
8642    #[test]
8643    fn memory_config_env_overrides_prompt_flags() {
8644        let _lock = env_lock_acquire();
8645        let temp_home = TempHome::new();
8646        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8647        let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
8648        let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
8649        let _relevant_recall_rerank =
8650            EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
8651        let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
8652
8653        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8654        let memory = config
8655            .memory
8656            .as_ref()
8657            .expect("memory config should be created by env overrides");
8658        assert!(!memory.project_prompt_injection);
8659        assert!(!memory.relevant_recall);
8660        assert!(memory.relevant_recall_rerank);
8661        assert!(!memory.project_first_dream);
8662    }
8663
8664    #[test]
8665    fn provider_api_keys_injected_from_env_and_never_persisted() {
8666        let _lock = env_lock_acquire();
8667        let temp_home = TempHome::new();
8668        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8669        let _anthropic = crate::test_support::override_runtime_env_var(
8670            "BAMBOO_ANTHROPIC_API_KEY",
8671            Some("sk-ant-from-env"),
8672        );
8673        let _openai = crate::test_support::override_runtime_env_var(
8674            "BAMBOO_OPENAI_API_KEY",
8675            Some("sk-oai-from-env"),
8676        );
8677        let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8678
8679        // No config.json on disk → the providers are created from the env keys
8680        // alone (#253: deploy without a plaintext api_key in a mounted file).
8681        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8682        assert_eq!(
8683            config
8684                .providers
8685                .anthropic
8686                .as_ref()
8687                .expect("anthropic created from env")
8688                .api_key,
8689            "sk-ant-from-env"
8690        );
8691        assert_eq!(
8692            config
8693                .providers
8694                .openai
8695                .as_ref()
8696                .expect("openai created from env")
8697                .api_key,
8698            "sk-oai-from-env"
8699        );
8700        // An unset provider is not fabricated.
8701        assert!(config.providers.gemini.is_none());
8702
8703        // The real "never persisted" guarantee: saving the config must NOT bake
8704        // the env key into config.json — not as plaintext AND not re-encrypted
8705        // into `api_key_encrypted` (which save's `refresh_provider_api_keys_encrypted`
8706        // would otherwise do). This is what actually happens on the server when
8707        // any unrelated setting is saved / on a fabric-reconcile boot.
8708        config
8709            .save_to_dir(temp_home.path.clone())
8710            .expect("save config");
8711        let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
8712            .expect("read persisted config.json");
8713        assert!(
8714            !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
8715            "env key must not be persisted as plaintext"
8716        );
8717        let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
8718        assert!(
8719            disk_json["providers"]["anthropic"]
8720                .get("api_key_encrypted")
8721                .is_none(),
8722            "env-sourced anthropic key must not be re-encrypted into config.json"
8723        );
8724        assert!(
8725            disk_json["providers"]["openai"]
8726                .get("api_key_encrypted")
8727                .is_none(),
8728            "env-sourced openai key must not be re-encrypted into config.json"
8729        );
8730
8731        // And once the env vars are gone, a reload from that same dir has no key
8732        // (nothing was persisted).
8733        drop(_anthropic);
8734        drop(_openai);
8735        let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
8736        assert!(reloaded
8737            .providers
8738            .anthropic
8739            .as_ref()
8740            .map(|a| a.api_key.is_empty())
8741            .unwrap_or(true));
8742    }
8743
8744    #[test]
8745    fn get_default_work_area_path_expands_tilde_and_requires_directory() {
8746        let _lock = env_lock_acquire();
8747        let temp_home = TempHome::new();
8748        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8749        let target = temp_home.path.join("workspace-default");
8750        std::fs::create_dir_all(&target).expect("default work area dir should exist");
8751
8752        let mut config = Config::default();
8753        config.default_work_area.replace(DefaultWorkAreaConfig {
8754            path: Some("~/workspace-default".to_string()),
8755        });
8756
8757        assert_eq!(config.get_default_work_area_path(), Some(target));
8758    }
8759
8760    #[test]
8761    fn get_default_work_area_path_returns_none_for_missing_directory() {
8762        let _lock = env_lock_acquire();
8763        let temp_home = TempHome::new();
8764        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8765
8766        let mut config = Config::default();
8767        config.default_work_area.replace(DefaultWorkAreaConfig {
8768            path: Some("~/missing-default-work-area".to_string()),
8769        });
8770
8771        assert!(config.get_default_work_area_path().is_none());
8772    }
8773
8774    #[test]
8775    fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
8776        let mut config = Config::default();
8777        config.tools.disabled = vec![
8778            "  read_file  ".to_string(),
8779            "".to_string(),
8780            "read_file".to_string(),
8781            "bash".to_string(),
8782            "default::getCurrentDir".to_string(),
8783            "default::applyPatch".to_string(),
8784            "default::custom_tool".to_string(),
8785            "mcp__alpha__inspect".to_string(),
8786        ];
8787
8788        config.normalize_tool_settings();
8789
8790        assert_eq!(
8791            config.tools.disabled,
8792            vec![
8793                "bash",
8794                "default::applyPatch",
8795                "default::custom_tool",
8796                "default::getCurrentDir",
8797                "mcp__alpha__inspect",
8798                "read_file"
8799            ]
8800        );
8801    }
8802
8803    #[test]
8804    fn config_load_preserves_disabled_references_for_catalog_resolution() {
8805        let _lock = env_lock_acquire();
8806        let temp_home = TempHome::new();
8807        temp_home.set_config_json(
8808            r#"{
8809  "tools": {
8810    "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
8811  }
8812}"#,
8813        );
8814
8815        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8816        assert_eq!(
8817            config.tools.disabled,
8818            vec!["bash", "default::getCurrentDir", "read_file"]
8819        );
8820        assert!(config.disabled_tool_references().contains("bash"));
8821        assert!(config.disabled_tool_references().contains("read_file"));
8822        assert!(config
8823            .disabled_tool_references()
8824            .contains("default::getCurrentDir"));
8825        assert_eq!(
8826            config.disabled_tool_names(),
8827            BTreeSet::from([
8828                "Bash".to_string(),
8829                "GetCurrentDir".to_string(),
8830                "Read".to_string()
8831            ])
8832        );
8833    }
8834
8835    #[test]
8836    fn normalize_skill_settings_trims_dedupes_and_sorts() {
8837        let mut config = Config::default();
8838        config.skills.disabled = vec![
8839            " pdf ".to_string(),
8840            "".to_string(),
8841            "pdf".to_string(),
8842            "skill-creator".to_string(),
8843        ];
8844
8845        config.normalize_skill_settings();
8846
8847        assert_eq!(
8848            config.skills.disabled,
8849            vec!["pdf".to_string(), "skill-creator".to_string()]
8850        );
8851    }
8852
8853    #[test]
8854    fn config_load_reads_disabled_skills_as_normalized_ids() {
8855        let _lock = env_lock_acquire();
8856        let temp_home = TempHome::new();
8857        temp_home.set_config_json(
8858            r#"{
8859  "skills": {
8860    "disabled": [" pdf ", "skill-creator", "pdf", ""]
8861  }
8862}"#,
8863        );
8864
8865        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8866        assert_eq!(
8867            config.skills.disabled,
8868            vec!["pdf".to_string(), "skill-creator".to_string()]
8869        );
8870        assert!(config.disabled_skill_ids().contains("pdf"));
8871        assert!(config.disabled_skill_ids().contains("skill-creator"));
8872    }
8873
8874    #[test]
8875    fn test_server_config_defaults() {
8876        let _lock = env_lock_acquire();
8877        let temp_home = TempHome::new();
8878
8879        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8880        assert_eq!(config.server.port, 9562);
8881        assert_eq!(config.server.bind, "127.0.0.1");
8882        assert_eq!(config.server.workers, 10);
8883        assert!(config.server.static_dir.is_none());
8884    }
8885
8886    #[test]
8887    fn test_server_addr() {
8888        let mut config = Config::default();
8889        config.server.port = 9000;
8890        config.server.bind = "0.0.0.0".to_string();
8891        assert_eq!(config.server_addr(), "0.0.0.0:9000");
8892    }
8893
8894    #[test]
8895    fn test_env_var_overrides() {
8896        let _lock = env_lock_acquire();
8897        let temp_home = TempHome::new();
8898
8899        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
8900        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
8901        let _provider =
8902            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8903
8904        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8905        assert_eq!(config.server.port, 9999);
8906        assert_eq!(config.server.bind, "192.168.1.1");
8907        assert_eq!(config.provider, "openai");
8908    }
8909
8910    #[test]
8911    fn test_config_save_and_load() {
8912        let _lock = env_lock_acquire();
8913        let temp_home = TempHome::new();
8914
8915        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
8916        config.server.port = 9000;
8917        config.server.bind = "0.0.0.0".to_string();
8918        config.provider = "anthropic".to_string();
8919
8920        // Save
8921        config
8922            .save_to_dir(temp_home.path.clone())
8923            .expect("Failed to save config");
8924
8925        // Load again
8926        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
8927
8928        // Verify
8929        assert_eq!(loaded.server.port, 9000);
8930        assert_eq!(loaded.server.bind, "0.0.0.0");
8931        assert_eq!(loaded.provider, "anthropic");
8932    }
8933
8934    #[test]
8935    fn modular_root_and_public_serde_preserve_legacy_shape() {
8936        let _lock = env_lock_acquire();
8937        let temp_home = TempHome::new();
8938        let input = serde_json::json!({
8939            "http_proxy": "http://proxy.example",
8940            "provider": "openai",
8941            "mcp": { "servers": [] },
8942            "future_extension": { "enabled": true },
8943            "memory": { "background_model": "memory-model" },
8944            "subagents": { "max_concurrent": 7 },
8945            "providers": { "openai": { "model": "chat-model" } }
8946        });
8947
8948        let config: Config = serde_json::from_value(input).unwrap();
8949        let public = serde_json::to_value(&config).unwrap();
8950        assert_eq!(public["http_proxy"], "http://proxy.example");
8951        assert!(public.get("mcp").is_none());
8952        assert!(public.get("mcpServers").is_some());
8953        assert_eq!(public["future_extension"]["enabled"], true);
8954        assert_eq!(public["memory"]["background_model"], "memory-model");
8955        assert_eq!(public["subagents"]["max_concurrent"], 7);
8956        assert_eq!(public["providers"]["openai"]["model"], "chat-model");
8957
8958        let round_tripped: Config = serde_json::from_value(public).unwrap();
8959        assert_eq!(
8960            round_tripped
8961                .memory()
8962                .as_ref()
8963                .unwrap()
8964                .background_model
8965                .as_deref(),
8966            Some("memory-model")
8967        );
8968        assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
8969        assert_eq!(
8970            round_tripped
8971                .providers()
8972                .openai
8973                .as_ref()
8974                .unwrap()
8975                .model
8976                .as_deref(),
8977            Some("chat-model")
8978        );
8979        assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
8980
8981        round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
8982        let persisted: Value =
8983            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
8984                .unwrap();
8985        assert_eq!(persisted["http_proxy"], "http://proxy.example");
8986        assert!(persisted.get("mcpServers").is_some());
8987        assert_eq!(persisted["future_extension"]["enabled"], true);
8988        assert!(persisted.get("memory").is_none());
8989        assert!(persisted.get("subagents").is_none());
8990        assert!(persisted.get("providers").is_none());
8991        for internal_section_name in [
8992            "network",
8993            "provider_routing",
8994            "model_behavior",
8995            "tooling",
8996            "workspace",
8997            "execution",
8998            "integrations",
8999            "plugin_security",
9000        ] {
9001            assert!(persisted.get(internal_section_name).is_none());
9002        }
9003    }
9004
9005    #[test]
9006    fn config_decrypts_proxy_auth_from_encrypted_field() {
9007        let _lock = env_lock_acquire();
9008        let temp_home = TempHome::new();
9009
9010        // Use a stable encryption key so this test doesn't depend on host identifiers.
9011        let key_guard = crate::encryption::set_test_encryption_key([
9012            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9013            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9014            0x1c, 0x1d, 0x1e, 0x1f,
9015        ]);
9016
9017        let auth = ProxyAuth {
9018            username: "user".to_string(),
9019            password: "pass".to_string(),
9020        };
9021        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9022        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9023
9024        temp_home.set_config_json(&format!(
9025            r#"{{
9026  "http_proxy": "http://proxy.example.com:8080",
9027  "proxy_auth_encrypted": "{encrypted}"
9028}}"#
9029        ));
9030        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9031        let loaded_auth = config
9032            .proxy_auth
9033            .as_ref()
9034            .expect("proxy auth should be hydrated");
9035        assert_eq!(loaded_auth.username, "user");
9036        assert_eq!(loaded_auth.password, "pass");
9037        drop(key_guard);
9038    }
9039
9040    #[test]
9041    fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9042        let _lock = env_lock_acquire();
9043        let temp_home = TempHome::new();
9044
9045        // Use a stable encryption key so this test doesn't depend on host identifiers.
9046        let key_guard = crate::encryption::set_test_encryption_key([
9047            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9048            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9049            0x1c, 0x1d, 0x1e, 0x1f,
9050        ]);
9051
9052        let auth = ProxyAuth {
9053            username: "user".to_string(),
9054            password: "pass".to_string(),
9055        };
9056        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9057        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9058
9059        // Simulate older Bodhi/Tauri persisted config keys.
9060        temp_home.set_config_json(&format!(
9061            r#"{{
9062  "http_proxy": "http://proxy.example.com:8080",
9063  "http_proxy_auth_encrypted": "{encrypted}",
9064  "https_proxy_auth_encrypted": "{encrypted}"
9065}}"#
9066        ));
9067
9068        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9069        let loaded_auth = config
9070            .proxy_auth
9071            .as_ref()
9072            .expect("proxy auth should be hydrated");
9073        assert_eq!(loaded_auth.username, "user");
9074        assert_eq!(loaded_auth.password, "pass");
9075        drop(key_guard);
9076    }
9077
9078    #[test]
9079    fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9080        let _lock = env_lock_acquire();
9081        let temp_home = TempHome::new();
9082
9083        // Use a stable encryption key so this test doesn't depend on host identifiers.
9084        let key_guard = crate::encryption::set_test_encryption_key([
9085            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9086            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9087            0x1c, 0x1d, 0x1e, 0x1f,
9088        ]);
9089
9090        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9091        config.proxy_auth = Some(ProxyAuth {
9092            username: "user".to_string(),
9093            password: "pass".to_string(),
9094        });
9095        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9096        assert!(error
9097            .to_string()
9098            .contains("isolated credential transaction"));
9099        let path = temp_home.path.join("config.json");
9100        assert!(
9101            !path.exists(),
9102            "a rejected unisolated secret must not create config.json"
9103        );
9104        drop(key_guard);
9105    }
9106
9107    #[test]
9108    fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9109        let _lock = env_lock_acquire();
9110        let temp_home = TempHome::new();
9111        let mut config = Config::default();
9112        config.env_vars.push(EnvVarEntry {
9113            name: "TOKEN".to_string(),
9114            value: String::new(),
9115            secret: true,
9116            value_encrypted: None,
9117            credential_ref: None,
9118            configured: true,
9119            description: None,
9120        });
9121        let path = temp_home.path.join("config.json");
9122
9123        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9124        assert!(error
9125            .to_string()
9126            .contains("isolated credential transaction"));
9127        assert!(
9128            !path.exists(),
9129            "a rejected dangling ref must not create config.json"
9130        );
9131
9132        let original = br#"{"preserve":"original"}"#;
9133        std::fs::write(&path, original).unwrap();
9134        config.save_to_dir(temp_home.path.clone()).unwrap_err();
9135        assert_eq!(
9136            std::fs::read(&path).unwrap(),
9137            original,
9138            "a rejected dangling ref must not modify an existing config.json"
9139        );
9140    }
9141
9142    #[test]
9143    fn config_save_persists_provider_reference_and_isolates_plaintext() {
9144        let _lock = env_lock_acquire();
9145        let temp_home = TempHome::new();
9146
9147        // Use a stable encryption key so this test doesn't depend on host identifiers.
9148        let key_guard = crate::encryption::set_test_encryption_key([
9149            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9150            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9151            0x1c, 0x1d, 0x1e, 0x1f,
9152        ]);
9153
9154        let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9155        crate::CredentialStore::open(&temp_home.path)
9156            .replace(
9157                reference.clone(),
9158                "sk-test-provider-key",
9159                crate::CredentialSource::User,
9160                0,
9161            )
9162            .unwrap();
9163        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9164        config.provider = "openai".to_string();
9165        config.providers.openai = Some(OpenAIConfig {
9166            api_key: "sk-test-provider-key".to_string(),
9167            api_key_encrypted: None,
9168            credential_ref: Some(reference),
9169            base_url: None,
9170            model: None,
9171            fast_model: None,
9172            vision_model: None,
9173            reasoning_effort: None,
9174            responses_only_models: vec![],
9175            request_overrides: None,
9176            extra: Default::default(),
9177            api_key_from_env: false,
9178        });
9179
9180        config
9181            .save_to_dir(temp_home.path.clone())
9182            .expect("save should persist provider reference");
9183
9184        let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9185            .expect("read providers.json");
9186        assert!(
9187            !content.contains("api_key_encrypted"),
9188            "providers.json must not store provider ciphertext"
9189        );
9190        assert!(
9191            !content.contains("\"api_key\""),
9192            "providers.json should not store plaintext provider keys"
9193        );
9194
9195        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9196        let openai = loaded
9197            .providers
9198            .openai
9199            .as_ref()
9200            .expect("openai config should be present");
9201        assert_eq!(openai.api_key, "sk-test-provider-key");
9202
9203        drop(key_guard);
9204    }
9205
9206    #[test]
9207    fn config_save_persists_mcp_servers_in_mainstream_format() {
9208        let _lock = env_lock_acquire();
9209        let temp_home = TempHome::new();
9210
9211        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9212
9213        let mut env = std::collections::HashMap::new();
9214        env.insert("TOKEN".to_string(), "supersecret".to_string());
9215
9216        config.mcp.servers = vec![
9217            bamboo_domain::mcp_config::McpServerConfig {
9218                id: "stdio-secret".to_string(),
9219                name: None,
9220                enabled: true,
9221                transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9222                    bamboo_domain::mcp_config::StdioConfig {
9223                        command: "echo".to_string(),
9224                        args: vec![],
9225                        cwd: None,
9226                        env,
9227                        env_encrypted: std::collections::HashMap::new(),
9228                        env_credential_refs: std::collections::HashMap::new(),
9229                        startup_timeout_ms: 5000,
9230                    },
9231                ),
9232                request_timeout_ms: 5000,
9233                healthcheck_interval_ms: 1000,
9234                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9235                allowed_tools: vec![],
9236                denied_tools: vec![],
9237            },
9238            bamboo_domain::mcp_config::McpServerConfig {
9239                id: "sse-secret".to_string(),
9240                name: None,
9241                enabled: true,
9242                transport: bamboo_domain::mcp_config::TransportConfig::Sse(
9243                    bamboo_domain::mcp_config::SseConfig {
9244                        url: "http://localhost:8080/sse".to_string(),
9245                        headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9246                            name: "Authorization".to_string(),
9247                            value: "Bearer token123".to_string(),
9248                            value_encrypted: None,
9249                            credential_ref: None,
9250                        }],
9251                        connect_timeout_ms: 5000,
9252                    },
9253                ),
9254                request_timeout_ms: 5000,
9255                healthcheck_interval_ms: 1000,
9256                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9257                allowed_tools: vec![],
9258                denied_tools: vec![],
9259            },
9260        ];
9261
9262        config
9263            .save_to_dir(temp_home.path.clone())
9264            .expect("save should persist MCP servers");
9265
9266        let content =
9267            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9268        assert!(
9269            content.contains("\"mcpServers\""),
9270            "config.json should store MCP servers under the mainstream 'mcpServers' key"
9271        );
9272        assert!(
9273            content.contains("supersecret"),
9274            "config.json should persist MCP stdio env in mainstream format"
9275        );
9276        assert!(
9277            content.contains("Bearer token123"),
9278            "config.json should persist MCP SSE headers in mainstream format"
9279        );
9280        assert!(
9281            !content.contains("\"env_encrypted\""),
9282            "config.json should not persist legacy env_encrypted fields"
9283        );
9284        assert!(
9285            !content.contains("\"value_encrypted\""),
9286            "config.json should not persist legacy value_encrypted fields"
9287        );
9288
9289        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9290        let stdio = loaded
9291            .mcp
9292            .servers
9293            .iter()
9294            .find(|s| s.id == "stdio-secret")
9295            .expect("stdio server should exist");
9296        match &stdio.transport {
9297            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9298                assert_eq!(
9299                    stdio.env.get("TOKEN").map(|s| s.as_str()),
9300                    Some("supersecret")
9301                );
9302            }
9303            _ => panic!("Expected stdio transport"),
9304        }
9305
9306        let sse = loaded
9307            .mcp
9308            .servers
9309            .iter()
9310            .find(|s| s.id == "sse-secret")
9311            .expect("sse server should exist");
9312        match &sse.transport {
9313            bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
9314                assert_eq!(sse.headers[0].value, "Bearer token123");
9315            }
9316            _ => panic!("Expected SSE transport"),
9317        }
9318    }
9319
9320    #[test]
9321    fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9322        let _lock = env_lock_acquire();
9323        let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9324        let temp_home = TempHome::new();
9325        let env_plaintext = "mcp-root-env-plaintext-597";
9326        let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9327        let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9328        std::fs::write(
9329            temp_home.path.join("config.json"),
9330            serde_json::to_vec_pretty(&serde_json::json!({
9331                "features": {"provider_model_ref": true},
9332                "mcpServers": {
9333                    "stdio-root": {
9334                        "command": "unused-disabled-command",
9335                        "env": {"TOKEN": env_plaintext},
9336                        "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9337                        "env_credential_refs": {
9338                            "TOKEN": "mcp.stdio-root.env_TOKEN"
9339                        }
9340                    },
9341                    "http-root": {
9342                        "url": "https://example.test/mcp",
9343                        "transport_kind": "streamable_http",
9344                        "headers": {"Authorization": header_plaintext},
9345                        "header_credential_refs": {
9346                            "Authorization": "mcp.http-root.header_Authorization"
9347                        }
9348                    }
9349                }
9350            }))
9351            .unwrap(),
9352        )
9353        .unwrap();
9354        std::fs::write(
9355            temp_home.path.join("mcp.json"),
9356            serde_json::to_vec_pretty(&serde_json::json!({
9357                "schema_version": 1,
9358                "revision": 7,
9359                "data": {
9360                    "stdio-root": {
9361                        "command": "unused-disabled-command",
9362                        "enabled": false,
9363                        "env_encrypted": {"TOKEN": legacy_ciphertext},
9364                        "request_timeout_ms": 100,
9365                        "healthcheck_interval_ms": 100
9366                    },
9367                    "http-root": {
9368                        "url": "https://example.test/mcp",
9369                        "transport_kind": "streamable_http",
9370                        "enabled": false,
9371                        "headers": {"Authorization": header_plaintext},
9372                        "request_timeout_ms": 100,
9373                        "healthcheck_interval_ms": 100
9374                    }
9375                }
9376            }))
9377            .unwrap(),
9378        )
9379        .unwrap();
9380
9381        crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9382        let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9383            temp_home.path.join("mcp.json"),
9384            1,
9385        )
9386        .load()
9387        .unwrap()
9388        .unwrap();
9389        let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9390        config.mcp = stored.data;
9391        config
9392            .hydrate_mcp_credentials_from_store(&temp_home.path)
9393            .unwrap();
9394
9395        let public = serde_json::to_value(&config.mcp).unwrap();
9396        let rendered_public = public.to_string();
9397        assert!(rendered_public.contains(env_plaintext));
9398        assert!(rendered_public.contains(header_plaintext));
9399        let compatible: bamboo_domain::mcp_config::McpConfig =
9400            serde_json::from_value(public).unwrap();
9401        assert_eq!(compatible.servers.len(), 2);
9402
9403        config.features.provider_model_ref = !config.features.provider_model_ref;
9404        config.save_to_dir(temp_home.path.clone()).unwrap();
9405        config.features.provider_model_ref = !config.features.provider_model_ref;
9406        config.save_to_dir(temp_home.path.clone()).unwrap();
9407
9408        for entry in std::fs::read_dir(&temp_home.path)
9409            .unwrap()
9410            .filter_map(Result::ok)
9411        {
9412            let name = entry.file_name().to_string_lossy().to_string();
9413            if name == "credentials.json" || entry.path().is_dir() {
9414                continue;
9415            }
9416            if name == "config.json"
9417                || name == "mcp.json"
9418                || name.contains(".bak")
9419                || name.starts_with("config-credential-migration")
9420            {
9421                let bytes = std::fs::read(entry.path()).unwrap();
9422                let content = String::from_utf8_lossy(&bytes);
9423                assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9424                assert!(
9425                    !content.contains(header_plaintext),
9426                    "secret leaked to {name}"
9427                );
9428                assert!(
9429                    !content.contains(&legacy_ciphertext),
9430                    "legacy ciphertext leaked to {name}"
9431                );
9432            }
9433        }
9434    }
9435
9436    // ── Env vars lifecycle tests ──────────────────────────────
9437
9438    #[test]
9439    fn env_vars_as_map_includes_only_non_empty_values() {
9440        let mut config = Config::default();
9441        config.env_vars.extend([
9442            EnvVarEntry {
9443                name: "A".to_string(),
9444                value: "val_a".to_string(),
9445                secret: false,
9446                value_encrypted: None,
9447                credential_ref: None,
9448                configured: true,
9449                description: None,
9450            },
9451            EnvVarEntry {
9452                name: "B".to_string(),
9453                value: "".to_string(), // empty → should be excluded
9454                secret: true,
9455                value_encrypted: None,
9456                credential_ref: None,
9457                configured: false,
9458                description: None,
9459            },
9460            EnvVarEntry {
9461                name: "C".to_string(),
9462                value: "  ".to_string(), // whitespace-only → excluded
9463                secret: false,
9464                value_encrypted: None,
9465                credential_ref: None,
9466                configured: true,
9467                description: None,
9468            },
9469            EnvVarEntry {
9470                name: "D".to_string(),
9471                value: "val_d".to_string(),
9472                secret: true,
9473                value_encrypted: Some("enc".to_string()),
9474                credential_ref: None,
9475                configured: true,
9476                description: Some("desc".to_string()),
9477            },
9478        ]);
9479
9480        let map = config.env_vars_as_map();
9481        assert_eq!(map.len(), 2);
9482        assert_eq!(map.get("A"), Some(&"val_a".to_string()));
9483        assert_eq!(map.get("D"), Some(&"val_d".to_string()));
9484        assert!(!map.contains_key("B"));
9485        assert!(!map.contains_key("C"));
9486    }
9487
9488    #[test]
9489    fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
9490        let mut config = Config::default();
9491        config.env_vars.extend([
9492            EnvVarEntry {
9493                name: "PLAIN".to_string(),
9494                value: "visible".to_string(),
9495                secret: false,
9496                value_encrypted: None,
9497                credential_ref: None,
9498                configured: true,
9499                description: None,
9500            },
9501            EnvVarEntry {
9502                name: "SECRET".to_string(),
9503                value: "hidden_value".to_string(),
9504                secret: true,
9505                value_encrypted: Some("enc_data".to_string()),
9506                credential_ref: None,
9507                configured: true,
9508                description: None,
9509            },
9510        ]);
9511
9512        config.sanitize_env_vars_for_disk();
9513
9514        assert_eq!(config.env_vars[0].value, "visible"); // plain kept
9515        assert_eq!(config.env_vars[1].value, ""); // secret cleared
9516    }
9517
9518    #[test]
9519    fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
9520        let mut config = Config::default();
9521        config.env_vars.extend([
9522            EnvVarEntry {
9523                name: "OPEN".to_string(),
9524                value: "val".to_string(),
9525                secret: false,
9526                value_encrypted: None,
9527                credential_ref: None,
9528                configured: true,
9529                description: None,
9530            },
9531            EnvVarEntry {
9532                name: "HIDDEN".to_string(),
9533                value: "real_secret".to_string(),
9534                secret: true,
9535                value_encrypted: Some("enc".to_string()),
9536                credential_ref: None,
9537                configured: true,
9538                description: None,
9539            },
9540        ]);
9541
9542        config.sanitize_env_vars_for_disk();
9543
9544        // Plain value untouched
9545        assert_eq!(config.env_vars[0].value, "val");
9546        // Secret plaintext and legacy ciphertext are removed.
9547        assert_eq!(config.env_vars[1].value, "");
9548        assert!(config.env_vars[1].value_encrypted.is_none());
9549    }
9550
9551    #[test]
9552    fn legacy_env_ciphertext_is_read_but_never_serialized() {
9553        let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
9554        let mut config: Config = serde_json::from_value(serde_json::json!({
9555            "env_vars": [{
9556                "name": "TOKEN",
9557                "secret": true,
9558                "value_encrypted": ciphertext
9559            }]
9560        }))
9561        .unwrap();
9562        config.hydrate_env_vars_from_encrypted();
9563        assert_eq!(config.env_vars[0].value, "my-secret-token");
9564        let serialized = serde_json::to_value(&config).unwrap();
9565        assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
9566    }
9567
9568    #[test]
9569    fn configured_env_ref_missing_from_store_fails_closed() {
9570        let dir = tempfile::tempdir().unwrap();
9571        let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
9572        let mut config = Config::default();
9573        config.env_vars.push(EnvVarEntry {
9574            name: "TOKEN".to_string(),
9575            value: String::new(),
9576            secret: true,
9577            value_encrypted: None,
9578            credential_ref: Some(reference),
9579            configured: true,
9580            description: None,
9581        });
9582        let error = config
9583            .hydrate_env_var_credentials_from_store(dir.path())
9584            .unwrap_err();
9585        assert!(error
9586            .to_string()
9587            .contains("referenced env credential is unavailable"));
9588        assert!(config.env_vars[0].configured);
9589        assert!(config.env_vars[0].value.is_empty());
9590    }
9591
9592    #[test]
9593    fn configured_notification_ref_missing_from_store_fails_closed() {
9594        let dir = tempfile::tempdir().unwrap();
9595        let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
9596        let mut config = Config::default();
9597        config.notifications.ntfy.credential_ref = Some(reference);
9598        config.notifications.ntfy.configured = true;
9599
9600        let error = config
9601            .hydrate_notification_credentials_from_store(dir.path())
9602            .unwrap_err();
9603
9604        assert!(error
9605            .to_string()
9606            .contains("referenced ntfy credential is unavailable"));
9607        assert!(config.notifications.ntfy.configured);
9608        assert!(config.notifications.ntfy.token.is_none());
9609    }
9610
9611    #[test]
9612    fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
9613        let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
9614        let dir = tempfile::tempdir().unwrap();
9615        let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
9616        crate::CredentialStore::open(dir.path())
9617            .replace(
9618                reference.clone(),
9619                "exclusive-notification-secret",
9620                crate::CredentialSource::User,
9621                0,
9622            )
9623            .unwrap();
9624
9625        let mut config = Config::default();
9626        config.notifications.ntfy.credential_ref = Some(reference.clone());
9627        config.notifications.ntfy.configured = true;
9628        config.proxy_auth_credential_ref = Some(reference.clone());
9629        let error = config
9630            .hydrate_notification_credentials_from_store(dir.path())
9631            .unwrap_err();
9632        let rendered = error.to_string();
9633        assert!(rendered
9634            .contains("notification credential reference is shared by another config consumer"));
9635        assert!(!rendered.contains(reference.as_str()));
9636        assert!(config.notifications.ntfy.token.is_none());
9637
9638        config.proxy_auth_credential_ref = None;
9639        config
9640            .hydrate_notification_credentials_from_store(dir.path())
9641            .unwrap();
9642        assert_eq!(
9643            config.notifications.ntfy.token.as_deref(),
9644            Some("exclusive-notification-secret")
9645        );
9646    }
9647
9648    #[test]
9649    fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
9650        let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
9651        let dir = tempfile::tempdir().unwrap();
9652        let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
9653        let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
9654        let bytes = serde_json::to_vec_pretty(&serde_json::json!({
9655            "notifications": {
9656                "ntfy": { "enabled": true, "token_encrypted": ntfy },
9657                "bark": { "enabled": true, "device_key_encrypted": bark }
9658            }
9659        }))
9660        .unwrap();
9661        std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
9662        std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
9663
9664        let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
9665
9666        assert!(loaded.notifications.ntfy.token.is_none());
9667        assert!(loaded.notifications.bark.device_key.is_none());
9668        assert_eq!(
9669            std::fs::read(dir.path().join("config.json")).unwrap(),
9670            bytes
9671        );
9672    }
9673
9674    #[test]
9675    fn publish_and_current_env_vars_round_trip() {
9676        // `publish_env_vars` REPLACES the process-global env-vars cache
9677        // wholesale, so every test that touches that cache must hold the
9678        // crate-wide env lock. This test didn't (issue #486): running
9679        // concurrently with a lock-holding cache test (e.g.
9680        // `from_data_dir_without_publish_does_not_clobber_global_cache`) it
9681        // wiped that test's just-seeded marker out of the cache mid-assert —
9682        // and its own 10x retry loop below was itself a symptom of losing
9683        // the same race in the other direction. With the lock held, one
9684        // publish is deterministic.
9685        let _lock = crate::test_support::env_cache_lock_acquire();
9686        let mut config = Config::default();
9687        config.env_vars.extend([EnvVarEntry {
9688            name: "TEST_PUBLISH".to_string(),
9689            value: "pub_value".to_string(),
9690            secret: false,
9691            value_encrypted: None,
9692            credential_ref: None,
9693            configured: true,
9694            description: None,
9695        }]);
9696
9697        config.publish_env_vars();
9698        assert_eq!(
9699            Config::current_env_vars()
9700                .get("TEST_PUBLISH")
9701                .map(String::as_str),
9702            Some("pub_value")
9703        );
9704    }
9705
9706    #[test]
9707    fn broker_token_round_trips_encrypt_sanitize_hydrate() {
9708        let mut config = Config::default();
9709        config.subagents.broker = Some(BrokerClientConfig {
9710            endpoint: "ws://127.0.0.1:9600".to_string(),
9711            token: "super-secret-token".to_string(),
9712            token_encrypted: None,
9713            credential_ref: None,
9714            configured: false,
9715        });
9716
9717        // Persist path: encrypt then sanitize (what save_to_dir does).
9718        config.refresh_broker_token_encrypted().unwrap();
9719        config.sanitize_broker_token_for_disk();
9720        let broker = config.subagents.broker.as_ref().unwrap();
9721        assert!(broker.token.is_empty(), "plaintext cleared for disk");
9722        assert!(broker.token_encrypted.is_some(), "ciphertext stored");
9723        assert_ne!(
9724            broker.token_encrypted.as_deref(),
9725            Some("super-secret-token")
9726        );
9727
9728        // Load path: hydrate restores plaintext.
9729        config.hydrate_broker_token_from_encrypted();
9730        assert_eq!(
9731            config.subagents.broker.as_ref().unwrap().token,
9732            "super-secret-token"
9733        );
9734    }
9735
9736    #[test]
9737    fn broker_token_empty_refresh_preserves_ciphertext() {
9738        // A redacted round-trip (token empty) must not wipe the stored ciphertext.
9739        let mut config = Config::default();
9740        config.subagents.broker = Some(BrokerClientConfig {
9741            endpoint: "ws://h:9600".to_string(),
9742            token: String::new(),
9743            token_encrypted: Some("existing-cipher".to_string()),
9744            credential_ref: None,
9745            configured: false,
9746        });
9747        config.refresh_broker_token_encrypted().unwrap();
9748        assert_eq!(
9749            config
9750                .subagents
9751                .broker
9752                .as_ref()
9753                .unwrap()
9754                .token_encrypted
9755                .as_deref(),
9756            Some("existing-cipher"),
9757        );
9758    }
9759
9760    #[test]
9761    fn notifications_config_defaults_when_key_missing() {
9762        // Additive/back-compat: an absent `notifications` key must deserialize
9763        // to the built-in defaults (desktop auto, ntfy/bark disabled).
9764        let config: Config = serde_json::from_str("{}").expect("empty object parses");
9765        assert_eq!(config.notifications, NotificationsConfig::default());
9766        assert_eq!(config.notifications.desktop.enabled, None);
9767        assert!(!config.notifications.ntfy.enabled);
9768        assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
9769        assert_eq!(config.notifications.ntfy.token, None);
9770        assert!(!config.notifications.bark.enabled);
9771        assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
9772        assert_eq!(config.notifications.bark.device_key, None);
9773    }
9774
9775    #[test]
9776    fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
9777        let mut config = Config::default();
9778        config.notifications.ntfy = NtfyChannelConfig {
9779            enabled: true,
9780            base_url: "https://ntfy.sh".to_string(),
9781            topic: "bamboo-alerts".to_string(),
9782            token: Some("tk_super_secret".to_string()),
9783            token_encrypted: None,
9784            credential_ref: None,
9785            configured: false,
9786        };
9787
9788        // Legacy compatibility path: retain a readable ciphertext until the
9789        // credential migration moves the secret into the isolated store.
9790        config.refresh_notifications_encrypted().unwrap();
9791        assert!(config.notifications.ntfy.token_encrypted.is_some());
9792        assert_ne!(
9793            config.notifications.ntfy.token_encrypted.as_deref(),
9794            Some("tk_super_secret")
9795        );
9796
9797        // Neither plaintext nor legacy ciphertext is serializable.
9798        let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
9799        assert!(
9800            !json.contains("tk_super_secret"),
9801            "plaintext token must never be serialized"
9802        );
9803        assert!(!json.contains("token_encrypted"));
9804
9805        // Legacy load path restores plaintext for migration.
9806        config.notifications.ntfy.token = None;
9807        config.hydrate_notifications_from_encrypted();
9808        assert_eq!(
9809            config.notifications.ntfy.token.as_deref(),
9810            Some("tk_super_secret")
9811        );
9812    }
9813
9814    #[test]
9815    fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
9816        let mut config = Config::default();
9817        config.notifications.bark = BarkChannelConfig {
9818            enabled: true,
9819            base_url: "https://api.day.app".to_string(),
9820            device_key: Some("dk_super_secret".to_string()),
9821            device_key_encrypted: None,
9822            credential_ref: None,
9823            configured: false,
9824        };
9825
9826        config.refresh_notifications_encrypted().unwrap();
9827        assert!(config.notifications.bark.device_key_encrypted.is_some());
9828        assert_ne!(
9829            config.notifications.bark.device_key_encrypted.as_deref(),
9830            Some("dk_super_secret")
9831        );
9832
9833        let json = serde_json::to_string(&config.notifications.bark).unwrap();
9834        assert!(
9835            !json.contains("dk_super_secret"),
9836            "plaintext device key must never be serialized"
9837        );
9838        assert!(!json.contains("device_key_encrypted"));
9839
9840        config.notifications.bark.device_key = None;
9841        config.hydrate_notifications_from_encrypted();
9842        assert_eq!(
9843            config.notifications.bark.device_key.as_deref(),
9844            Some("dk_super_secret")
9845        );
9846    }
9847
9848    #[test]
9849    fn notification_secrets_empty_refresh_preserves_ciphertext() {
9850        // The legacy compatibility helper retains already-loaded ciphertext;
9851        // ordinary persistence sanitizes it before writing.
9852        let mut config = Config::default();
9853        config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
9854        config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
9855
9856        config.refresh_notifications_encrypted().unwrap();
9857
9858        assert_eq!(
9859            config.notifications.ntfy.token_encrypted.as_deref(),
9860            Some("existing-ntfy-cipher")
9861        );
9862        assert_eq!(
9863            config.notifications.bark.device_key_encrypted.as_deref(),
9864            Some("existing-bark-cipher")
9865        );
9866    }
9867
9868    #[test]
9869    fn hydrate_skips_non_secret_entries() {
9870        let mut config = Config::default();
9871        config.env_vars.extend([EnvVarEntry {
9872            name: "PLAIN".to_string(),
9873            value: "original".to_string(),
9874            secret: false,
9875            value_encrypted: Some("should-be-ignored".to_string()),
9876            credential_ref: None,
9877            configured: true,
9878            description: None,
9879        }]);
9880
9881        config.hydrate_env_vars_from_encrypted();
9882        // Non-secret entry should keep its original value
9883        assert_eq!(config.env_vars[0].value, "original");
9884    }
9885
9886    #[test]
9887    fn default_config_has_empty_env_vars() {
9888        // `Config::default()` is a pure in-memory constructor (no disk read, no
9889        // env overrides), so this is independent of the developer's
9890        // `~/.bamboo/config.json` — no temp-dir isolation needed. Directly
9891        // asserts the #38 invariant that default() does not touch the filesystem.
9892        assert!(Config::default().env_vars.is_empty());
9893    }
9894
9895    #[test]
9896    fn serde_round_trip_with_env_vars() {
9897        let mut config = Config::default();
9898        config.env_vars.extend([
9899            EnvVarEntry {
9900                name: "KEY1".to_string(),
9901                value: "val1".to_string(),
9902                secret: false,
9903                value_encrypted: None,
9904                credential_ref: None,
9905                configured: true,
9906                description: Some("First key".to_string()),
9907            },
9908            EnvVarEntry {
9909                name: "KEY2".to_string(),
9910                value: "".to_string(), // on-disk secret has no plaintext
9911                secret: true,
9912                value_encrypted: Some("enc123".to_string()),
9913                credential_ref: None,
9914                configured: true,
9915                description: None,
9916            },
9917        ]);
9918
9919        let json = serde_json::to_string(&config).unwrap();
9920        let restored: Config = serde_json::from_str(&json).unwrap();
9921
9922        assert_eq!(restored.env_vars.len(), 2);
9923        assert_eq!(restored.env_vars[0].name, "KEY1");
9924        assert_eq!(restored.env_vars[0].value, "val1");
9925        assert!(!restored.env_vars[0].secret);
9926        assert_eq!(restored.env_vars[1].name, "KEY2");
9927        assert!(restored.env_vars[1].secret);
9928        assert!(restored.env_vars[1].value_encrypted.is_none());
9929    }
9930
9931    // ---- defaults.* model resolution tests ----
9932
9933    #[test]
9934    // fields set conditionally below
9935    #[allow(clippy::field_reassign_with_default)]
9936    fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
9937        let mut config = Config::default();
9938        config.provider = "openai".to_string();
9939        config.providers.openai = Some(OpenAIConfig {
9940            api_key: "test".to_string(),
9941            api_key_encrypted: None,
9942            credential_ref: None,
9943            base_url: None,
9944            model: Some("legacy-gpt-4o".to_string()),
9945            fast_model: None,
9946            vision_model: None,
9947            reasoning_effort: None,
9948            responses_only_models: vec![],
9949            request_overrides: None,
9950            extra: Default::default(),
9951            api_key_from_env: false,
9952        });
9953        config.features.provider_model_ref = true;
9954        config.defaults = Some(DefaultsConfig {
9955            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9956            fast: None,
9957            task_summary: None,
9958            vision: None,
9959            memory_background: None,
9960            planning: None,
9961            search: None,
9962            code_review: None,
9963            sub_agent: None,
9964            subagent_models: Default::default(),
9965        });
9966
9967        assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
9968    }
9969
9970    #[test]
9971    // fields set conditionally below
9972    #[allow(clippy::field_reassign_with_default)]
9973    fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
9974        let mut config = Config::default();
9975        config.provider = "openai".to_string();
9976        config.providers.openai = Some(OpenAIConfig {
9977            api_key: "test".to_string(),
9978            api_key_encrypted: None,
9979            credential_ref: None,
9980            base_url: None,
9981            model: Some("legacy-gpt-4o".to_string()),
9982            fast_model: None,
9983            vision_model: None,
9984            reasoning_effort: None,
9985            responses_only_models: vec![],
9986            request_overrides: None,
9987            extra: Default::default(),
9988            api_key_from_env: false,
9989        });
9990        config.features.provider_model_ref = false;
9991        config.defaults = Some(DefaultsConfig {
9992            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
9993            fast: None,
9994            task_summary: None,
9995            vision: None,
9996            memory_background: None,
9997            planning: None,
9998            search: None,
9999            code_review: None,
10000            sub_agent: None,
10001            subagent_models: Default::default(),
10002        });
10003
10004        assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10005    }
10006
10007    #[test]
10008    // fields set conditionally below
10009    #[allow(clippy::field_reassign_with_default)]
10010    fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10011        let mut config = Config::default();
10012        config.provider = "openai".to_string();
10013        config.providers.openai = Some(OpenAIConfig {
10014            api_key: "test".to_string(),
10015            api_key_encrypted: None,
10016            credential_ref: None,
10017            base_url: None,
10018            model: Some("gpt-4o".to_string()),
10019            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10020            vision_model: None,
10021            reasoning_effort: None,
10022            responses_only_models: vec![],
10023            request_overrides: None,
10024            extra: Default::default(),
10025            api_key_from_env: false,
10026        });
10027        config.features.provider_model_ref = true;
10028        config.defaults = Some(DefaultsConfig {
10029            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10030            fast: Some(bamboo_domain::ProviderModelRef::new(
10031                "anthropic",
10032                "claude-3-5-haiku",
10033            )),
10034            task_summary: None,
10035            vision: None,
10036            memory_background: None,
10037            planning: None,
10038            search: None,
10039            code_review: None,
10040            sub_agent: None,
10041            subagent_models: Default::default(),
10042        });
10043
10044        assert_eq!(
10045            config.get_fast_model(),
10046            Some("claude-3-5-haiku".to_string())
10047        );
10048    }
10049
10050    #[test]
10051    // fields set conditionally below
10052    #[allow(clippy::field_reassign_with_default)]
10053    fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10054        let mut config = Config::default();
10055        config.provider = "openai".to_string();
10056        config.providers.openai = Some(OpenAIConfig {
10057            api_key: "test".to_string(),
10058            api_key_encrypted: None,
10059            credential_ref: None,
10060            base_url: None,
10061            model: Some("gpt-4o".to_string()),
10062            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10063            vision_model: None,
10064            reasoning_effort: None,
10065            responses_only_models: vec![],
10066            request_overrides: None,
10067            extra: Default::default(),
10068            api_key_from_env: false,
10069        });
10070        config.features.provider_model_ref = false;
10071        config.defaults = Some(DefaultsConfig {
10072            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10073            fast: Some(bamboo_domain::ProviderModelRef::new(
10074                "anthropic",
10075                "claude-3-5-haiku",
10076            )),
10077            task_summary: None,
10078            vision: None,
10079            memory_background: None,
10080            planning: None,
10081            search: None,
10082            code_review: None,
10083            sub_agent: None,
10084            subagent_models: Default::default(),
10085        });
10086
10087        assert_eq!(
10088            config.get_fast_model(),
10089            Some("legacy-gpt-4o-mini".to_string())
10090        );
10091    }
10092
10093    #[test]
10094    // fields set conditionally below
10095    #[allow(clippy::field_reassign_with_default)]
10096    fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10097        let mut config = Config::default();
10098        config.provider = "openai".to_string();
10099        config.features.provider_model_ref = true;
10100        config.defaults = Some(DefaultsConfig {
10101            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10102            fast: None,
10103            task_summary: None,
10104            vision: None,
10105            memory_background: None,
10106            planning: None,
10107            search: None,
10108            code_review: None,
10109            sub_agent: None,
10110            subagent_models: Default::default(),
10111        });
10112
10113        assert_eq!(
10114            config.get_fast_model(),
10115            Some("claude-3-7-sonnet".to_string())
10116        );
10117    }
10118
10119    #[test]
10120    // fields set conditionally below
10121    #[allow(clippy::field_reassign_with_default)]
10122    fn get_memory_background_model_prefers_defaults_memory_background() {
10123        let mut config = Config::default();
10124        config.provider = "openai".to_string();
10125        config.providers.openai = Some(OpenAIConfig {
10126            api_key: "test".to_string(),
10127            api_key_encrypted: None,
10128            credential_ref: None,
10129            base_url: None,
10130            model: Some("gpt-4o".to_string()),
10131            fast_model: Some("gpt-4o-mini".to_string()),
10132            vision_model: None,
10133            reasoning_effort: None,
10134            responses_only_models: vec![],
10135            request_overrides: None,
10136            extra: Default::default(),
10137            api_key_from_env: false,
10138        });
10139        config.features.provider_model_ref = true;
10140        config.defaults = Some(DefaultsConfig {
10141            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10142            fast: Some(bamboo_domain::ProviderModelRef::new(
10143                "openai",
10144                "gpt-4o-mini",
10145            )),
10146            task_summary: None,
10147            vision: None,
10148            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10149                "anthropic",
10150                "claude-3-5-haiku",
10151            )),
10152            planning: None,
10153            search: None,
10154            code_review: None,
10155            sub_agent: None,
10156            subagent_models: Default::default(),
10157        });
10158
10159        assert_eq!(
10160            config.get_memory_background_model(),
10161            Some("claude-3-5-haiku".to_string())
10162        );
10163    }
10164
10165    #[test]
10166    // fields set conditionally below
10167    #[allow(clippy::field_reassign_with_default)]
10168    fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10169        let mut config = Config::default();
10170        config.provider = "openai".to_string();
10171        config.features.provider_model_ref = true;
10172        config.defaults = Some(DefaultsConfig {
10173            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10174            fast: Some(bamboo_domain::ProviderModelRef::new(
10175                "anthropic",
10176                "claude-3-5-haiku",
10177            )),
10178            task_summary: None,
10179            vision: None,
10180            memory_background: None,
10181            planning: None,
10182            search: None,
10183            code_review: None,
10184            sub_agent: None,
10185            subagent_models: Default::default(),
10186        });
10187
10188        assert_eq!(
10189            config.get_memory_background_model(),
10190            Some("claude-3-5-haiku".to_string())
10191        );
10192    }
10193
10194    #[test]
10195    // fields set conditionally below
10196    #[allow(clippy::field_reassign_with_default)]
10197    fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10198        let mut config = Config::default();
10199        config.provider = "openai".to_string();
10200        config.providers.openai = Some(OpenAIConfig {
10201            api_key: "test".to_string(),
10202            api_key_encrypted: None,
10203            credential_ref: None,
10204            base_url: None,
10205            model: Some("gpt-4o".to_string()),
10206            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10207            vision_model: None,
10208            reasoning_effort: None,
10209            responses_only_models: vec![],
10210            request_overrides: None,
10211            extra: Default::default(),
10212            api_key_from_env: false,
10213        });
10214        config.features.provider_model_ref = false;
10215        config.defaults = Some(DefaultsConfig {
10216            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10217            fast: Some(bamboo_domain::ProviderModelRef::new(
10218                "anthropic",
10219                "claude-3-5-haiku",
10220            )),
10221            task_summary: None,
10222            vision: None,
10223            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10224                "anthropic",
10225                "claude-3-5-haiku",
10226            )),
10227            planning: None,
10228            search: None,
10229            code_review: None,
10230            sub_agent: None,
10231            subagent_models: Default::default(),
10232        });
10233
10234        assert_eq!(
10235            config.get_memory_background_model(),
10236            Some("legacy-gpt-4o-mini".to_string())
10237        );
10238    }
10239
10240    // -------------------------------------------------------------------
10241    // `is_host_trusted` — plugin source-trust host allowlist (component
10242    // matching, not raw string-prefix matching; see the function's own docs
10243    // for the bypasses this closes).
10244    // -------------------------------------------------------------------
10245
10246    #[test]
10247    fn is_host_trusted_requires_https_scheme() {
10248        let hosts = vec!["github.com/bigduu/".to_string()];
10249        assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10250        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10251    }
10252
10253    #[test]
10254    fn is_host_trusted_is_case_insensitive_on_both_sides() {
10255        // A lowercase URL host against a mixed-case config entry...
10256        let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10257        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10258        // ...and a mixed-case URL host against a lowercase config entry.
10259        let hosts = vec!["github.com/bigduu/".to_string()];
10260        assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10261    }
10262
10263    #[test]
10264    fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10265        let hosts = vec!["trusted.example.com".to_string()];
10266        assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10267        // Both demonstrated bypasses of a raw string-prefix match: gluing a
10268        // longer attacker-controlled label onto the trusted host, with or
10269        // without a separating dot.
10270        assert!(!is_host_trusted(
10271            "https://trusted.example.com.evil.com/x",
10272            &hosts
10273        ));
10274        assert!(!is_host_trusted(
10275            "https://trusted.example.comevil.com/x",
10276            &hosts
10277        ));
10278    }
10279
10280    #[test]
10281    fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10282        // No trailing slash on the config entry's path component.
10283        let hosts = vec!["github.com/bigduu/".to_string()];
10284        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10285        assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10286    }
10287
10288    #[test]
10289    fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10290        let hosts = vec!["example.com".to_string()];
10291        assert!(is_host_trusted("https://example.com/", &hosts));
10292        assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10293        // Still only that exact host — a bare-host entry must not become a
10294        // blanket "any host containing this string" match.
10295        assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10296        assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10297    }
10298
10299    #[test]
10300    fn is_host_trusted_uses_the_real_host_not_userinfo() {
10301        let hosts = vec!["github.com/bigduu/".to_string()];
10302        // `user@host` userinfo does not change the actual host.
10303        assert!(is_host_trusted(
10304            "https://someuser@github.com/bigduu/x",
10305            &hosts
10306        ));
10307        // A decoy host placed in the userinfo position must not be mistaken
10308        // for the real host — the real host here is `evil.com`.
10309        assert!(!is_host_trusted(
10310            "https://github.com@evil.com/bigduu/",
10311            &hosts
10312        ));
10313    }
10314
10315    #[test]
10316    fn is_host_trusted_ignores_an_explicit_port() {
10317        let hosts = vec!["github.com/bigduu/".to_string()];
10318        assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10319    }
10320
10321    #[test]
10322    fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10323        let hosts = vec!["github.com/bigduu/".to_string()];
10324        assert!(!is_host_trusted("not a url at all", &hosts));
10325        assert!(!is_host_trusted("", &hosts));
10326        assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); // no scheme
10327    }
10328
10329    #[test]
10330    fn is_host_trusted_normalizes_dot_segments_before_matching() {
10331        let hosts = vec!["github.com/bigduu/".to_string()];
10332        // `Url::parse` resolves `..` segments before `path()` is ever
10333        // consulted, so this cannot be used to escape the trusted prefix.
10334        assert!(!is_host_trusted(
10335            "https://github.com/bigduu/../evil/x",
10336            &hosts
10337        ));
10338        // A `..` that stays under the trusted prefix once resolved is fine.
10339        assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10340    }
10341
10342    #[test]
10343    fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10344        let mut config = Config::default();
10345        config.plugin_trust.trusted_hosts = vec![
10346            "  GitHub.com/BigDuu/ ".to_string(),
10347            "".to_string(),
10348            "   ".to_string(),
10349            "Example.COM".to_string(),
10350        ];
10351        config.normalize_plugin_trust_settings();
10352        assert_eq!(
10353            config.plugin_trust.trusted_hosts,
10354            vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10355        );
10356    }
10357
10358    // -----------------------------------------------------------------
10359    // `plugin_trust.enforcement` — the persistent, config-level form of the
10360    // `--insecure` escape hatch.
10361    // -----------------------------------------------------------------
10362
10363    #[test]
10364    fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10365        // A fresh `Config::default()` (nothing on disk at all).
10366        let config = Config::default();
10367        assert_eq!(
10368            config.plugin_trust.enforcement,
10369            PluginTrustEnforcement::Strict
10370        );
10371        assert!(!config.plugin_trust.enforcement_is_off());
10372
10373        // A `plugin_trust` object present in JSON but with NO `enforcement`
10374        // key at all (e.g. a config.json written before this field existed)
10375        // must ALSO deserialize to Strict, not fail or silently do something
10376        // else — additive/back-compat, matching `trusted_hosts`/
10377        // `trusted_keys`'s own `#[serde(default = ...)]` behavior.
10378        let json = serde_json::json!({
10379            "trusted_hosts": ["example.com"],
10380            "trusted_keys": [],
10381        });
10382        let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10383        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10384    }
10385
10386    #[test]
10387    fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10388        for raw in ["off", "OFF", "Off", " off "] {
10389            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10390                "enforcement": raw,
10391            }))
10392            .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10393            assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10394            assert!(trust.enforcement_is_off());
10395        }
10396        for raw in ["strict", "STRICT", " Strict "] {
10397            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10398                "enforcement": raw,
10399            }))
10400            .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10401            assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10402        }
10403
10404        let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10405            "enforcement": "nonsense",
10406        }))
10407        .expect_err("an unrecognized string must be rejected, not silently default");
10408        assert!(err.to_string().contains("nonsense"));
10409    }
10410
10411    #[test]
10412    fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10413        // A hand-edited config.json using a plain bool reads naturally: is
10414        // enforcement ON (`true`) or OFF (`false`)?
10415        let trust: PluginTrustConfig =
10416            serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10417        assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10418
10419        let trust: PluginTrustConfig =
10420            serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10421        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10422    }
10423
10424    #[test]
10425    fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10426        // Regardless of which accepted input form produced it, the
10427        // in-memory value always serializes back out as the canonical
10428        // string — this is what the dot-path `config set` setter's
10429        // round-trip check relies on (see `dot_path.rs`'s module docs).
10430        let trust = PluginTrustConfig {
10431            enforcement: PluginTrustEnforcement::Off,
10432            ..PluginTrustConfig::default()
10433        };
10434        let json = serde_json::to_value(&trust).unwrap();
10435        assert_eq!(json["enforcement"], "off");
10436
10437        let trust = PluginTrustConfig {
10438            enforcement: PluginTrustEnforcement::Strict,
10439            ..PluginTrustConfig::default()
10440        };
10441        let json = serde_json::to_value(&trust).unwrap();
10442        assert_eq!(json["enforcement"], "strict");
10443    }
10444
10445    #[test]
10446    fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
10447        // `normalize_plugin_trust_settings` only touches `trusted_hosts` —
10448        // confirm it's a true no-op on `enforcement` either way.
10449        let mut config = Config::default();
10450        config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
10451        config.normalize_plugin_trust_settings();
10452        assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
10453    }
10454
10455    #[test]
10456    fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
10457        // Confirms the dot-path `bamboo config set plugin_trust.enforcement
10458        // off` path actually works end to end through
10459        // `crate::dot_path::apply_dot_path_set` (the generic JSON-patch
10460        // setter), not just direct field assignment.
10461        let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
10462            "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
10463        )));
10464        assert_eq!(
10465            config.plugin_trust.enforcement,
10466            PluginTrustEnforcement::Strict
10467        );
10468
10469        let outcome = crate::dot_path::apply_dot_path_set(
10470            &config,
10471            "plugin_trust.enforcement",
10472            crate::dot_path::parse_cli_value("off"),
10473        )
10474        .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
10475        assert_eq!(
10476            outcome.config.plugin_trust.enforcement,
10477            PluginTrustEnforcement::Off
10478        );
10479
10480        // And back to strict.
10481        let outcome = crate::dot_path::apply_dot_path_set(
10482            &outcome.config,
10483            "plugin_trust.enforcement",
10484            crate::dot_path::parse_cli_value("strict"),
10485        )
10486        .expect("setting it back to strict should also round-trip");
10487        assert_eq!(
10488            outcome.config.plugin_trust.enforcement,
10489            PluginTrustEnforcement::Strict
10490        );
10491    }
10492}