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