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/// User-selected locations for new user worktrees. Git remains the registry.
222#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
223pub struct WorktreeSettings {
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub default_root: Option<String>,
226    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
227    pub project_roots: BTreeMap<String, String>,
228}
229
230impl WorktreeSettings {
231    pub fn is_empty(&self) -> bool {
232        self.default_root.is_none() && self.project_roots.is_empty()
233    }
234}
235
236/// Access control configuration for password-based UI/API gating.
237#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
238pub struct AccessControlConfig {
239    /// Whether password protection is enabled.
240    #[serde(default)]
241    pub password_enabled: bool,
242    /// A malformed legacy verifier or device record was isolated into the
243    /// encrypted recovery store and needs an explicit user repair. Runtime
244    /// authorization treats this as fail-closed even when no usable verifier
245    /// can be hydrated.
246    #[serde(default)]
247    pub repair_required: bool,
248    /// Runtime-only password verifier hash. Legacy documents may still
249    /// deserialize it for migration, but ordinary section serialization never
250    /// writes verifier material.
251    #[serde(default, skip_serializing)]
252    pub password_hash: Option<String>,
253    /// Runtime-only password verifier salt.
254    #[serde(default, skip_serializing)]
255    pub password_salt: Option<String>,
256    /// Stable reference to the encrypted verifier record.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub password_credential_ref: Option<crate::CredentialRef>,
259    /// Durable configured metadata for redacted clients/runtime readiness.
260    #[serde(default)]
261    pub password_configured: bool,
262    /// Last update timestamp for auditing / debugging.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub updated_at: Option<String>,
265    /// v2 (#181): issued per-device tokens. Empty = root-password-only mode
266    /// (back-compat with old instances). Each entry stores only the token hash;
267    /// the plaintext token is returned to the client once at pairing time.
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub devices: Vec<DeviceCredential>,
270}
271
272/// A single paired device's credential (v2-P2 per-device token, #181).
273///
274/// The server stores only `token_hash` (never the plaintext token). The hash is
275/// computed with the SAME construction as the access password — `SHA-256(salt ||
276/// token)` — so no new crypto dependency is introduced (`docs/api-v2-transport.md`
277/// §4.2).
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279pub struct DeviceCredential {
280    /// Server-generated stable id: `bamboo_<12 hex>`.
281    pub device_id: String,
282    /// Human-readable label, e.g. "iPhone 15".
283    pub label: String,
284    /// Runtime-only `SHA-256(hex_decode(token_salt) || token)`, hex-encoded.
285    #[serde(default, skip_serializing)]
286    pub token_hash: String,
287    /// Runtime-only per-device salt (hex-encoded).
288    #[serde(default, skip_serializing)]
289    pub token_salt: String,
290    /// Stable reference to the encrypted device-token verifier record.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub token_credential_ref: Option<crate::CredentialRef>,
293    /// Durable configured metadata for runtime readiness.
294    #[serde(default)]
295    pub token_configured: bool,
296    /// RFC3339 creation timestamp.
297    pub created_at: String,
298    /// RFC3339 last-used timestamp (deferred stamping; see PR).
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub last_used_at: Option<String>,
301    /// Whether this device's token has been revoked. A revoked token is rejected
302    /// at the handshake/middleware immediately.
303    #[serde(default)]
304    pub revoked: bool,
305}
306
307/// Memory and background summarization configuration.
308// No `Eq`: `dedup_gardener_min_score` is an f64 (PartialEq only).
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
310pub struct MemoryConfig {
311    /// Optional dedicated model for memory/session summarization and reflection.
312    /// Falls back to the provider fast model when unset.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub background_model: Option<String>,
315    /// Desired conversation-summary size as a fraction of the raw source tokens
316    /// represented by that summary. The default keeps roughly 20% of source
317    /// content as durable working memory.
318    #[serde(default = "default_summary_target_ratio")]
319    pub summary_target_ratio: f64,
320    /// Maximum fraction of the summarization model context window consumed by a
321    /// fully rendered map/reduce request, including reserved output and safety
322    /// margin.
323    #[serde(default = "default_summary_safe_window_percent")]
324    pub summary_safe_window_percent: u8,
325    /// Whether lightweight automatic Dream-style consolidation should run in the
326    /// background. Default ON (memory redesign L4): each tick no-ops when there is
327    /// no background model configured or no new candidate sessions, so it is free
328    /// until there is real work + a model. Set false to opt out.
329    #[serde(default = "default_true_auto_dream_enabled")]
330    pub auto_dream_enabled: bool,
331    /// Seconds between background auto-Dream ticks (default 30 minutes).
332    /// Each tick still no-ops when there are no new candidate sessions, so raising
333    /// this only lowers how often an active user triggers a real consolidation.
334    #[serde(default = "default_auto_dream_interval_secs")]
335    pub auto_dream_interval_secs: u64,
336    /// Whether project durable-memory index injection is enabled for the main prompt.
337    #[serde(
338        default = "default_true_memory_project_prompt_injection",
339        alias = "memory_project_prompt_injection"
340    )]
341    pub project_prompt_injection: bool,
342    /// Whether automatic relevant durable-memory recall is enabled for the main prompt.
343    #[serde(
344        default = "default_true_memory_relevant_recall",
345        alias = "memory_relevant_recall"
346    )]
347    pub relevant_recall: bool,
348    /// Whether relevant durable-memory recall should rerank lexical shortlist candidates
349    /// using the configured memory/background model.
350    #[serde(default, alias = "memory_relevant_recall_rerank")]
351    pub relevant_recall_rerank: bool,
352    /// Whether Dream prompt injection should prefer project Dream and only use global Dream as fallback.
353    #[serde(
354        default = "default_true_memory_project_first_dream",
355        alias = "memory_project_first_dream"
356    )]
357    pub project_first_dream: bool,
358    /// Whether the ledger agenda (overdue/upcoming prospective records — todos,
359    /// events, reminders) is injected into the main prompt. Free when the
360    /// ledger is empty: the section is simply omitted.
361    #[serde(
362        default = "default_true_memory_ledger_agenda",
363        alias = "memory_ledger_agenda_injection"
364    )]
365    pub ledger_agenda_injection: bool,
366    /// Whether the background ledger gardener runs (expires past events/reminders,
367    /// reconciles record↔schedule drift, distills completed records into durable
368    /// memory). Expiry and reconciliation are deterministic and free; only
369    /// distillation uses the background model, and it no-ops without one.
370    #[serde(default = "default_true_ledger_gardener_enabled")]
371    pub ledger_gardener_enabled: bool,
372    /// Seconds between ledger gardener runs (default 6 hours).
373    #[serde(default = "default_ledger_gardener_interval_secs")]
374    pub ledger_gardener_interval_secs: u64,
375    /// Whether the ledger gardener's distillation pass (completed records →
376    /// durable memories via the background model) is enabled.
377    #[serde(default = "default_true_ledger_distillation_enabled")]
378    pub ledger_distillation_enabled: bool,
379    /// DEPRECATED (memory redesign L3): the "Refine" Dream mode — rewriting the
380    /// notebook from its own prior prose — was retired because a self-referential
381    /// narrative rewrite drifts from durable truth and silently over-merges. The
382    /// notebook is now always a grounded VIEW of the durable memory index (Rebuild)
383    /// or a session bootstrap (Incremental). This field is IGNORED; it is retained
384    /// only so existing config files that set it still deserialize.
385    #[serde(default, alias = "memory_dream_refine_mode")]
386    pub dream_refine_mode: bool,
387    /// Whether the background "gardener" may use the LLM to split/merge "blob" memories.
388    /// Default ON (memory redesign L4). The deterministic blob prefilter is cheap
389    /// and each run is bounded by `gardener_max_splits_per_run`; a run that finds
390    /// nothing, or finds work but has no background model, spends no tokens. Set
391    /// false to opt out.
392    #[serde(
393        default = "default_true_gardener_enabled",
394        alias = "memory_gardener_enabled"
395    )]
396    pub gardener_enabled: bool,
397    /// Seconds between gardener time-triggered runs (default daily). A run may also
398    /// fire early when the library grows — see `gardener_volume_trigger`.
399    #[serde(default = "default_gardener_interval_secs")]
400    pub gardener_interval_secs: u64,
401    /// Run the gardener maintenance pass early (before the next time tick) once this
402    /// many new durable memories have accumulated since the last run, so pileup is
403    /// bounded by growth, not only by the clock (memory redesign L4). 0 disables the
404    /// volume trigger (time-only). Per-run caps still bound the work done.
405    #[serde(default = "default_gardener_volume_trigger")]
406    pub gardener_volume_trigger: usize,
407    /// Hard cap on LLM-backed splits per gardener run (cost ceiling per run).
408    #[serde(default = "default_gardener_max_splits_per_run")]
409    pub gardener_max_splits_per_run: usize,
410    /// Minimum `---` accretions for a memory to be a gardener split candidate.
411    #[serde(default = "default_gardener_min_sections")]
412    pub gardener_min_sections: usize,
413    /// Whether the background dedup gardener may use the LLM to consolidate
414    /// near-duplicate memories. Default ON (memory redesign L4); bounded by
415    /// `dedup_gardener_max_merges_per_run` and no-ops without a model. Set false to
416    /// opt out.
417    #[serde(
418        default = "default_true_dedup_gardener_enabled",
419        alias = "memory_dedup_gardener_enabled"
420    )]
421    pub dedup_gardener_enabled: bool,
422    /// Minimum content-keyword Jaccard (0.0–1.0) for two active memories to be
423    /// flagged as dedup candidates by the deterministic prefilter.
424    #[serde(default = "default_dedup_gardener_min_score")]
425    pub dedup_gardener_min_score: f64,
426    /// Hard cap on LLM-backed consolidations per dedup gardener run (cost ceiling).
427    #[serde(default = "default_dedup_gardener_max_merges_per_run")]
428    pub dedup_gardener_max_merges_per_run: usize,
429    /// Max RECALLABLE (Active/Stale) memories per scope before the capacity gardener
430    /// archives the lowest-value overflow OUT of the recall index (memory redesign
431    /// L5 — archive, never delete; reversible). 0 = unbounded (feature OFF, the
432    /// default): consequential enough to be opt-in, since L4's dedup already curbs
433    /// most growth. `Reference`/`User`/`Feedback` memories are always exempt — so
434    /// the effective floor is the count of exempt Active memories in a scope; set
435    /// this comfortably above that (a capacity below it is a no-op, not a purge).
436    #[serde(default)]
437    pub memory_active_capacity: usize,
438    /// Hard cap on how many memories the capacity gardener archives per run, so a
439    /// large overflow drains gradually instead of in one burst.
440    #[serde(default = "default_capacity_max_archivals_per_run")]
441    pub capacity_max_archivals_per_run: usize,
442    /// Whether the background freshness gardener may conservatively demote Active
443    /// day/week-granularity memories to Stale once they cross their documented
444    /// staleness window (issue #61 phase 2; applied by Jiandu's
445    /// `MemoryStore::expire_stale_granularity`). Default ON,
446    /// matching the other gardener passes: deterministic (no LLM, no cost), and
447    /// non-destructive — it only ever moves Active → Stale, never archives or
448    /// deletes. Set false to opt out.
449    #[serde(default = "default_true_granularity_freshness_gardener_enabled")]
450    pub granularity_freshness_gardener_enabled: bool,
451}
452
453impl Default for MemoryConfig {
454    fn default() -> Self {
455        Self {
456            background_model: None,
457            summary_target_ratio: default_summary_target_ratio(),
458            summary_safe_window_percent: default_summary_safe_window_percent(),
459            auto_dream_enabled: default_true_auto_dream_enabled(),
460            auto_dream_interval_secs: default_auto_dream_interval_secs(),
461            project_prompt_injection: default_true_memory_project_prompt_injection(),
462            relevant_recall: default_true_memory_relevant_recall(),
463            relevant_recall_rerank: false,
464            project_first_dream: default_true_memory_project_first_dream(),
465            ledger_agenda_injection: default_true_memory_ledger_agenda(),
466            ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
467            ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
468            ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
469            dream_refine_mode: false,
470            gardener_enabled: default_true_gardener_enabled(),
471            gardener_interval_secs: default_gardener_interval_secs(),
472            gardener_volume_trigger: default_gardener_volume_trigger(),
473            gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
474            gardener_min_sections: default_gardener_min_sections(),
475            dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
476            dedup_gardener_min_score: default_dedup_gardener_min_score(),
477            dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
478            memory_active_capacity: 0,
479            capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
480            granularity_freshness_gardener_enabled:
481                default_true_granularity_freshness_gardener_enabled(),
482        }
483    }
484}
485
486fn default_summary_target_ratio() -> f64 {
487    0.20
488}
489
490fn default_summary_safe_window_percent() -> u8 {
491    80
492}
493
494fn default_true_granularity_freshness_gardener_enabled() -> bool {
495    true
496}
497
498fn default_capacity_max_archivals_per_run() -> usize {
499    50
500}
501
502fn default_true_auto_dream_enabled() -> bool {
503    true
504}
505
506fn default_true_gardener_enabled() -> bool {
507    true
508}
509
510fn default_true_dedup_gardener_enabled() -> bool {
511    true
512}
513
514fn default_true_memory_ledger_agenda() -> bool {
515    true
516}
517
518fn default_true_ledger_gardener_enabled() -> bool {
519    true
520}
521
522fn default_ledger_gardener_interval_secs() -> u64 {
523    21_600
524}
525
526fn default_true_ledger_distillation_enabled() -> bool {
527    true
528}
529
530/// Fire the gardener maintenance pass early once ~this many new memories accumulate
531/// since the last run. Conservative: large enough to avoid thrashing on a few
532/// writes, small enough to bound pileup well under a full (daily) interval.
533fn default_gardener_volume_trigger() -> usize {
534    25
535}
536
537fn default_gardener_interval_secs() -> u64 {
538    86_400
539}
540
541fn default_auto_dream_interval_secs() -> u64 {
542    60 * 30
543}
544
545fn default_gardener_max_splits_per_run() -> usize {
546    8
547}
548
549fn default_gardener_min_sections() -> usize {
550    5
551}
552
553fn default_dedup_gardener_min_score() -> f64 {
554    0.6
555}
556
557fn default_dedup_gardener_max_merges_per_run() -> usize {
558    8
559}
560
561fn default_true_memory_project_prompt_injection() -> bool {
562    true
563}
564
565fn default_true_memory_relevant_recall() -> bool {
566    true
567}
568
569fn default_true_memory_project_first_dream() -> bool {
570    true
571}
572
573/// Per-run resource guardrails (issue #221): a cost/resource ceiling applied
574/// across an entire `AgentRuntime::execute()` call (i.e. one user turn's worth
575/// of internal rounds — the same "run" granularity the round cap uses).
576///
577/// Every field is `None` by default (unlimited), matching the rest of this
578/// config's opt-in-only posture. A per-request `ExecuteRequest::run_budget`
579/// override (HTTP `POST /execute` body) may only TIGHTEN this config-level
580/// default, never loosen it — per field, the effective limit is the minimum
581/// of the two (see [`RunBudgetConfig::merged_with_override`] and
582/// `bamboo_engine::runtime::runtime::AgentRuntime::execute`).
583///
584/// Exceeding any configured limit gracefully stops the run (mirrors the
585/// round-cap exhaustion path: one final summary turn, then a terminal stop
586/// with `runtime.completion_reason` = `"budget_exceeded"` for resource fields
587/// and `"max_rounds_reached"` for the round cap, plus a structured
588/// `AgentEvent::BudgetExceeded`) rather than erroring out — the run stays
589/// resumable.
590#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
591pub struct RunBudgetConfig {
592    /// Maximum total tokens (prompt + completion, actual provider-reported
593    /// usage summed across the run's rounds) before the run is stopped.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub max_total_tokens: Option<u64>,
596    /// Maximum total tool calls (across every round of the run, not just one
597    /// round — see `max_tool_calls_per_round` for the existing per-round cap)
598    /// before the run is stopped.
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub max_tool_calls: Option<u32>,
601    /// Maximum total `SubAgent` create calls (across the whole run) before the
602    /// run is stopped. Distinct from `subagents.max_concurrent`, which caps how
603    /// many child actor processes run AT ONCE, not how many a single run may
604    /// spawn in total over its lifetime.
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub max_subagents: Option<u32>,
607    /// Maximum internal rounds for a single run before the run is gracefully
608    /// stopped. `None` (the default) means unlimited: the loop runs until the
609    /// model stops calling tools, another guardrail trips, or the run is
610    /// cancelled. Setting a cap keeps the issue #29 exhaustion contract
611    /// (`runtime.completion_reason = "max_rounds_reached"`, a visible
612    /// notification, and exactly one final summary turn).
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub max_rounds: Option<u32>,
615}
616
617/// Tighten-only per-field merge: the effective limit is the MINIMUM of the
618/// config default and the request override, with `None` = unlimited.
619fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
620    match (config_default, request) {
621        (Some(a), Some(b)) => Some(a.min(b)),
622        (Some(a), None) => Some(a),
623        (None, Some(b)) => Some(b),
624        (None, None) => None,
625    }
626}
627
628impl RunBudgetConfig {
629    /// Merge a per-request override with this config-level default,
630    /// **tighten-only** (issue #221, PR #539 review): per field, the
631    /// effective limit is the MINIMUM of the two (`None` = unlimited), so a
632    /// `POST /execute` caller can lower a budget below the operator's
633    /// configured ceiling but can never raise or remove it.
634    ///
635    /// Rationale: `run_budget` is a defensive cost circuit-breaker, and the
636    /// server's other guardrails (`max_rounds`, per-round tool caps, …) are
637    /// not client-overridable at all. A client-loosenable ceiling would be no
638    /// ceiling: any caller of `/execute` could send
639    /// `max_total_tokens: u64::MAX` and erase the operator's cap. Overrides
640    /// looser than the config default are silently clamped to it rather than
641    /// rejected — the caller still gets the strictest applicable budget,
642    /// which is always a safe interpretation of their request.
643    pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
644        let Some(over) = request_override else {
645            return *self;
646        };
647        Self {
648            max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
649            max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
650            max_subagents: min_limit(self.max_subagents, over.max_subagents),
651            max_rounds: min_limit(self.max_rounds, over.max_rounds),
652        }
653    }
654}
655
656/// Authentication/provider posture for `executor = "codex"`.
657///
658/// `None` at the containing config field is interpreted as [`Self::Bamboo`],
659/// keeping old documents backward-compatible while making the safe,
660/// parent-routed mode the runtime default.
661#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
662#[serde(rename_all = "snake_case")]
663pub enum CodexAuthMode {
664    /// Use the invoking user's default `~/.codex` auth and configuration.
665    Inherit,
666    /// Isolated `CODEX_HOME`; `OPENAI_API_KEY` must be explicitly forwarded.
667    ApiKey,
668    /// Isolated `CODEX_HOME` with a custom provider and referenced credential.
669    Custom,
670    /// Isolated `CODEX_HOME`; route through this Bamboo server with a per-run token.
671    #[default]
672    Bamboo,
673}
674
675/// Codex 0.144+ only accepts the Responses wire protocol for custom providers.
676#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
677#[serde(rename_all = "snake_case")]
678pub enum CodexWireApi {
679    #[default]
680    Responses,
681}
682
683/// Codex transport used by `executor = "codex"`.
684#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(rename_all = "snake_case")]
686pub enum CodexMode {
687    /// One `codex exec --json` process per activation.
688    #[default]
689    Exec,
690    /// Long-lived `codex app-server` JSON-RPC session with approval relay.
691    AppServer,
692}
693
694/// OS sandbox selected for non-interactive `codex exec` children.
695#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(rename_all = "kebab-case")]
697pub enum CodexSandbox {
698    ReadOnly,
699    WorkspaceWrite,
700    DangerFullAccess,
701}
702
703/// Codex approval policy. Mode-specific validation rejects interactive policy
704/// in exec mode and non-interactive policy in app-server mode.
705#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
706#[serde(rename_all = "kebab-case")]
707pub enum CodexApprovalPolicy {
708    Never,
709    OnFailure,
710    OnRequest,
711}
712
713/// Default number of child levels below a Root (whose depth is zero).
714pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
715
716/// Sub-agent execution settings.
717///
718/// Sub-agents always run as independent **actor** processes — an isolated OS
719/// process with its own context (crash isolation, true parallelism, per-child
720/// resource limits). The historical in-process runtime was removed, so there is
721/// no longer a runtime toggle (a stray `"runtime"`/`"overrides"` key in an old
722/// config is ignored). The worker binary, its arguments, and the discovery
723/// directory are derived automatically (the current `bamboo` executable +
724/// `subagent-worker`); the expert fields below override them only when you run a
725/// custom worker.
726#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
727pub struct SubagentsConfig {
728    /// Maximum child depth below Root=0. Zero disables new child creation.
729    /// Unset preserves the historical four-level limit.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub max_spawn_depth: Option<u32>,
732    /// Maximum actor activations running at once; further spawns wait their
733    /// turn. Default: 200. Warm-idle process retention has a separate, smaller
734    /// bound.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub max_concurrent: Option<usize>,
737    /// Expert: custom worker binary. Default: the current bamboo executable.
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub worker_bin: Option<String>,
740    /// Expert: arguments for the custom worker binary. Default for the
741    /// built-in worker: `["subagent-worker"]`.
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub worker_args: Option<Vec<String>>,
744    /// Expert: discovery fabric directory. Default: a per-user temp dir.
745    #[serde(default, skip_serializing_if = "Option::is_none")]
746    pub fabric_dir: Option<String>,
747    /// Expert: `"echo"` swaps in a dependency-free smoke executor (no LLM)
748    /// to verify the actor chain end-to-end; `"claude_code"` drives the
749    /// official Claude Code CLI; `"codex"` drives the selected Codex mode.
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub executor: Option<String>,
752    /// `executor = "claude_code"` only: override the `claude` executable.
753    /// `None` runs `claude` resolved from `PATH`.
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub claude_code_binary: Option<String>,
756    /// `executor = "claude_code"` only: `--model` override. `None` omits the
757    /// flag (CLI default).
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub claude_code_model: Option<String>,
760    /// `executor = "claude_code"` only: `--permission-mode` override. `None`
761    /// still passes an EXPLICIT `default` to the CLI (issue #443 — the
762    /// headless stream-json default is `auto`, which self-approves every
763    /// tool and never asks); it does not mean "omit the flag".
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub claude_code_permission_mode: Option<String>,
766    /// `executor = "claude_code"` only: `true` lets the child inherit the
767    /// invoking user's `~/.claude` MCP servers/skills/settings. `false`/unset
768    /// (the default) isolates it (`--strict-mcp-config` +
769    /// `--setting-sources project`).
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub claude_code_inherit_user_config: Option<bool>,
772    /// `executor = "claude_code"` only: extra env var NAMES forwarded
773    /// verbatim from this process's env to the child, on top of the fixed
774    /// HOME/PATH/SHELL/TERM/LANG/LC_*/TMPDIR/USER/LOGNAME allowlist.
775    /// Forwarding `ANTHROPIC_API_KEY` here is an explicit opt-in that flips
776    /// billing from the CLI's own subscription auth to the API key.
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub claude_code_forward_env: Option<Vec<String>>,
779    /// `executor = "codex"` only: override the `codex` executable. `None`
780    /// resolves `codex` from `PATH`.
781    #[serde(default, skip_serializing_if = "Option::is_none")]
782    pub codex_binary: Option<String>,
783    /// `executor = "codex"` only: `--model` override. `None` uses the CLI
784    /// default model.
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub codex_model: Option<String>,
787    /// `executor = "codex"` only: `exec` (default) or long-lived `app_server`.
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub codex_mode: Option<CodexMode>,
790    /// `executor = "codex"` only: authentication/provider mode. Unset defaults
791    /// to `bamboo`, which keeps provider credentials in the parent process.
792    #[serde(default, skip_serializing_if = "Option::is_none")]
793    pub codex_auth_mode: Option<CodexAuthMode>,
794    /// `codex_auth_mode = "custom"` only: absolute HTTP(S) provider base URL.
795    #[serde(default, skip_serializing_if = "Option::is_none")]
796    pub codex_base_url: Option<String>,
797    /// `codex_auth_mode = "custom"` only. Codex 0.144+ supports `responses`.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub codex_wire_api: Option<CodexWireApi>,
800    /// `codex_auth_mode = "custom"` only: stable reference to an existing
801    /// provider credential. The plaintext key remains in the credential store
802    /// and is injected into the child process environment only.
803    #[serde(default, skip_serializing_if = "Option::is_none")]
804    pub codex_provider_key_ref: Option<crate::CredentialRef>,
805    /// Extra env var names copied into Codex after `env_clear()`. API-key mode
806    /// requires an explicit `OPENAI_API_KEY` entry; it is never implicit.
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub codex_forward_env: Option<Vec<String>>,
809    /// Explicit Codex sandbox override. Unset derives the sandbox from the
810    /// child permission profile and the parent session's bypass posture.
811    #[serde(default, skip_serializing_if = "Option::is_none")]
812    pub codex_sandbox: Option<CodexSandbox>,
813    /// Mode-specific approval policy. Exec resolves to a non-interactive safe
814    /// value; app-server requires `on-request` and routes it to the parent.
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub codex_approval_policy: Option<CodexApprovalPolicy>,
817    /// Permit network access from a workspace-write sandbox.
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub codex_network_access: Option<bool>,
820    /// Opt in to disabling the Codex OS sandbox, but only when the parent run
821    /// is itself in bypass mode. False/unset keeps bypass runs sandboxed.
822    #[serde(default, skip_serializing_if = "Option::is_none")]
823    pub codex_allow_danger_bypass: Option<bool>,
824    /// The active message-broker endpoint the `ask_agent` tool / sub-agent bus
825    /// dials. RUNTIME-ONLY (`#[serde(skip)]`): never read from nor written to
826    /// `config.json`. It is populated in memory each boot by `maybe_embed_broker`
827    /// — either from a user-managed external broker in `<data_dir>/broker.json`,
828    /// or from the freshly-embedded in-process broker (whose ephemeral loopback
829    /// port must NEVER be persisted, else a later boot dials a dead port).
830    #[serde(skip)]
831    pub broker: Option<BrokerClientConfig>,
832    /// Remote placements: pin specific sub-agent roles to resident workers
833    /// reached over `wss://` instead of a locally-spawned subprocess
834    /// (remote-actor-plan §3.4 / P1.5, #193). Empty (the default) keeps every
835    /// role on the local path — fully back-compatible: an old config with no
836    /// `remote_placements` key deserializes to an empty vec.
837    #[serde(default, skip_serializing_if = "Vec::is_empty")]
838    pub remote_placements: Vec<RemoteActorPlacement>,
839    /// Schedulable placements: route specific sub-agent roles to a LIVE worker
840    /// resolved from the agent registry at run time, instead of a locally-spawned
841    /// subprocess (remote-actor-plan §3.4 / P2b, #181). Unlike `remote_placements`
842    /// (a fixed endpoint), a schedulable placement names a logical `pool` and a
843    /// `registry_url`; the engine queries the registry for live workers in that
844    /// pool and picks one. Empty (the default) keeps every role on the local path
845    /// — fully back-compatible: an old config with no `schedulable_placements` key
846    /// deserializes to an empty vec.
847    ///
848    /// PRECEDENCE: if a role appears in BOTH `remote_placements` and
849    /// `schedulable_placements`, the fixed remote placement wins (it is resolved
850    /// first in `build_spec`).
851    #[serde(default, skip_serializing_if = "Vec::is_empty")]
852    pub schedulable_placements: Vec<SchedulablePlacement>,
853    /// Per-role allowlist scoping which host-bound MCP tools a sub-agent role
854    /// may see/call through the orchestrator's MCP proxy (issue #54;
855    /// `bamboo_broker::RoleToolAllowlist`). Read and enforced
856    /// ORCHESTRATOR-side when wiring `serve_mcp_proxy` — this is deliberately
857    /// NOT part of the worker-facing `McpProxyConfig` a deployed worker
858    /// receives, because a worker self-declaring its own allowlist would be
859    /// insecure (it could simply claim to be unrestricted). A role absent
860    /// from this list is unrestricted (sees/can call every proxiable tool),
861    /// so adding this policy never silently strips tools from an
862    /// already-deployed role you have not listed here. Empty (the default)
863    /// keeps every role unrestricted — fully backward compatible with
864    /// pre-#54 behavior.
865    ///
866    /// Role AND tool names are matched by exact string equality against the
867    /// worker-asserted `AgentRef.role` / the requested tool name — see
868    /// `RoleToolAllowlist`'s doc comment for the resulting self-asserted-role
869    /// caveat (this policy is adequate against a confused/hallucinating
870    /// worker, not a malicious one that lies about its own role).
871    #[serde(default, skip_serializing_if = "Vec::is_empty")]
872    pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
873}
874
875impl SubagentsConfig {
876    pub fn effective_max_spawn_depth(&self) -> u32 {
877        self.max_spawn_depth.unwrap_or(DEFAULT_MAX_SPAWN_DEPTH)
878    }
879}
880
881/// One role's MCP proxy tool allowlist entry (issue #54). See
882/// [`SubagentsConfig::mcp_role_allowlist`].
883#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
884pub struct McpRoleAllowlistEntry {
885    /// Sub-agent role this entry restricts — matches the worker-asserted
886    /// `AgentRef.role` (itself the child session's `subagent_type` /
887    /// `ChildIdentity.role`). There is no fixed registry of valid roles in
888    /// this codebase (roles are free-form profile ids), so a typo here is
889    /// NOT caught against a "known roles" list — only structurally (blank
890    /// names are dropped, duplicates warn) at load time. Double-check this
891    /// against the role string your profile/deploy config actually uses.
892    pub role: String,
893    /// Tool names this role may see in its manifest / call through the
894    /// proxy, matched by exact string equality against the backend's
895    /// registered tool name. An entry with an EMPTY list is an explicit
896    /// lockout (no tools) for that role, distinct from the role being absent
897    /// from this Vec entirely (unrestricted). Validated at load time against
898    /// the orchestrator's live MCP tool set where available — an unknown
899    /// name is still enforced (kept) but logged as a likely typo.
900    #[serde(default)]
901    pub tools: Vec<String>,
902}
903
904/// Operator-owned hard constraints for a remote WorkerHost. The broker's
905/// authenticated peer policy advertises capacity; it does not choose the
906/// trust/data boundary for a Child. Missing requirements leave an explicitly
907/// selected remote placement unavailable rather than inferring authority from
908/// the worker's own capabilities.
909#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
910#[serde(deny_unknown_fields)]
911pub struct OperatorPlacementRequirements {
912    pub trust_zone: String,
913    #[serde(default, skip_serializing_if = "Option::is_none")]
914    pub workspace_label: Option<String>,
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub network_zone: Option<String>,
917    #[serde(default)]
918    pub require_network_isolation: bool,
919    /// Operator-declared tools the selected Host must support. The runner adds
920    /// a native tool ceiling when one is bound to the Child.
921    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
922    pub required_tools: std::collections::BTreeSet<String>,
923}
924
925/// The fixed parent identity used by a scoped broker pool. Every candidate
926/// WorkerHost is separately selected by its authenticated observation.
927#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
928#[serde(deny_unknown_fields)]
929pub struct SchedulableBrokerParent {
930    pub parent_mailbox: String,
931    pub parent_role: String,
932}
933
934/// Route a role to a capacity-managed WorkerHost pool through a scoped WSS
935/// broker. The operator supplies the parent identity and hard constraints;
936/// legacy entries remain readable but unavailable until migrated.
937#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
938pub struct SchedulablePlacement {
939    /// Sub-agent role this targets (matches the child session's
940    /// `metadata["subagent_type"]`).
941    pub role: String,
942    /// Logical pool name — the registry `role` to query for live workers.
943    pub pool: String,
944    /// Scoped WSS broker endpoint. The old HTTP registry was retired.
945    #[serde(default, skip_serializing_if = "String::is_empty")]
946    pub registry_url: String,
947    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
948    /// `auth_ref`). Used for BOTH the registry query and the worker connect.
949    /// `None` ⇒ query/connect without a bearer (trusted link only).
950    #[serde(default, skip_serializing_if = "Option::is_none")]
951    pub token_env: Option<String>,
952    /// PEM file pinning the scoped broker certificate.
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    pub ca_cert_file: Option<String>,
955    /// Required for authority-backed scheduling. `registry_url` is the scoped
956    /// WSS broker URL for this route; legacy entries remain readable but cannot
957    /// dispatch a Run without a trusted parent identity and requirements.
958    #[serde(default, skip_serializing_if = "Option::is_none")]
959    pub broker_parent: Option<SchedulableBrokerParent>,
960    #[serde(default, skip_serializing_if = "Option::is_none")]
961    pub placement_requirements: Option<OperatorPlacementRequirements>,
962}
963
964/// Pins a single sub-agent role to a remote resident worker (remote-actor-plan
965/// §3.4 / P1.5). A child whose `subagent_type` matches `role` is connected over
966/// `wss://` to `endpoint` (Bearer-authenticated) instead of being spawned as a
967/// local subprocess. No role match ⇒ that child stays on the local path.
968///
969/// The bearer token is NEVER stored here in the clear: `token_env` names the
970/// environment variable that holds it (mirroring the A2A `auth_ref` pattern),
971/// read once at runner-build time. A `token_env` that is set-but-unset at build
972/// time leaves an explicit placement unavailable. A `broker_peer` selection
973/// is required for execution; legacy direct-worker config remains readable.
974#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
975pub struct RemoteActorPlacement {
976    /// Sub-agent role this targets (matches the child session's
977    /// `metadata["subagent_type"]`).
978    pub role: String,
979    /// Broker endpoint for a scoped peer route. Legacy worker endpoints remain
980    /// readable but are unavailable until migrated to a broker route.
981    pub endpoint: String,
982    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
983    /// `auth_ref`). `None` ⇒ connect without a bearer (trusted link only).
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub token_env: Option<String>,
986    /// PEM file pinning a self-signed worker cert. `None` ⇒ default webpki roots
987    /// (or plaintext `ws://`).
988    #[serde(default, skip_serializing_if = "Option::is_none")]
989    pub ca_cert_file: Option<String>,
990    /// Explicit scoped broker route. Absence keeps the placement selected but
991    /// unavailable, never a direct parent-to-worker connection.
992    #[serde(default, skip_serializing_if = "Option::is_none")]
993    pub broker_peer: Option<RemoteBrokerPeer>,
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub placement_requirements: Option<OperatorPlacementRequirements>,
996}
997
998/// Operator-pinned transport identities; these do not grant Actor ownership.
999#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1000#[serde(deny_unknown_fields)]
1001pub struct RemoteBrokerPeer {
1002    pub parent_mailbox: String,
1003    pub worker_mailbox: String,
1004    pub parent_role: Option<String>,
1005    pub worker_role: Option<String>,
1006}
1007impl RemoteBrokerPeer {
1008    pub fn valid(&self) -> bool {
1009        fn identifier(s: &str, mailbox: bool) -> bool {
1010            !s.is_empty()
1011                && s.len() <= 256
1012                && s != "."
1013                && s != ".."
1014                && s.bytes()
1015                    .all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
1016                && (!mailbox || !s.bytes().any(|b| b.is_ascii_uppercase()))
1017        }
1018        identifier(&self.parent_mailbox, true)
1019            && identifier(&self.worker_mailbox, true)
1020            && self.parent_mailbox != self.worker_mailbox
1021            && self
1022                .parent_role
1023                .as_deref()
1024                .is_none_or(|s| identifier(s, false))
1025            && self
1026                .worker_role
1027                .as_deref()
1028                .is_some_and(|s| identifier(s, false))
1029    }
1030}
1031
1032/// How to reach the central sub-agent message broker (`bamboo broker serve`).
1033#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
1034pub struct BrokerClientConfig {
1035    /// Broker WebSocket endpoint, e.g. `ws://broker-host:9600`.
1036    pub endpoint: String,
1037    /// Bearer token presented in the broker handshake.
1038    ///
1039    /// Runtime-only plaintext hydrated from the isolated credential store.
1040    /// Legacy `broker.json` files may still deserialize this field so the
1041    /// manifest migration can extract it, but serializers never emit it.
1042    #[serde(default, skip_serializing)]
1043    pub token: String,
1044    /// Legacy inline ciphertext accepted only for credential migration.
1045    #[serde(default, skip_serializing)]
1046    pub token_encrypted: Option<String>,
1047    /// Stable reference to the external broker bearer token.
1048    #[serde(default, skip_serializing_if = "Option::is_none")]
1049    pub credential_ref: Option<crate::CredentialRef>,
1050    /// Durable configured metadata. Runtime hydration still verifies that the
1051    /// referenced credential exists and decrypts successfully.
1052    #[serde(default)]
1053    pub configured: bool,
1054}
1055
1056impl std::fmt::Debug for BrokerClientConfig {
1057    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1058        formatter
1059            .debug_struct("BrokerClientConfig")
1060            .field("endpoint", &self.endpoint)
1061            .field("credential_ref", &self.credential_ref)
1062            .field("configured", &self.configured)
1063            .finish_non_exhaustive()
1064    }
1065}
1066
1067/// Native desktop (OS-notification) delivery channel.
1068#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1069pub struct DesktopChannelConfig {
1070    /// `None` = auto: on when Bamboo runs as a standalone `bamboo serve`
1071    /// process, off when spawned as a sidecar under `--parent-pid` (a native
1072    /// shell such as Bodhi owns notification UX in that mode — desktop
1073    /// notifications from both the sidecar and the shell would double-fire).
1074    /// `Some(_)` is an explicit user override of that default in either
1075    /// direction.
1076    #[serde(default, skip_serializing_if = "Option::is_none")]
1077    pub enabled: Option<bool>,
1078}
1079
1080/// [ntfy.sh](https://ntfy.sh) push notification channel (self-hostable).
1081#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1082pub struct NtfyChannelConfig {
1083    #[serde(default)]
1084    pub enabled: bool,
1085    /// ntfy server base URL (public ntfy.sh or a self-hosted instance).
1086    #[serde(default = "default_ntfy_base_url")]
1087    pub base_url: String,
1088    /// Topic to publish to. Priority mapping from notification category is
1089    /// left to the delivery sink, not configured here.
1090    #[serde(default)]
1091    pub topic: String,
1092    /// Access token for a protected/self-hosted ntfy instance (public ntfy.sh
1093    /// topics need none).
1094    ///
1095    /// Secret plaintext hydrated in memory from the isolated credential store;
1096    /// never serialized in ordinary config.
1097    #[serde(default, skip_serializing)]
1098    pub token: Option<String>,
1099    /// Legacy encrypted ciphertext accepted only for migration.
1100    #[serde(default, skip_serializing)]
1101    pub token_encrypted: Option<String>,
1102    /// Stable isolated credential-store reference.
1103    #[serde(default, skip_serializing_if = "Option::is_none")]
1104    pub credential_ref: Option<crate::CredentialRef>,
1105    /// Whether the referenced credential is expected to exist.
1106    #[serde(default)]
1107    pub configured: bool,
1108}
1109
1110impl Default for NtfyChannelConfig {
1111    fn default() -> Self {
1112        Self {
1113            enabled: false,
1114            base_url: default_ntfy_base_url(),
1115            topic: String::new(),
1116            token: None,
1117            token_encrypted: None,
1118            credential_ref: None,
1119            configured: false,
1120        }
1121    }
1122}
1123
1124fn default_ntfy_base_url() -> String {
1125    "https://ntfy.sh".to_string()
1126}
1127
1128/// [Bark](https://github.com/Finb/Bark) iOS push notification channel.
1129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1130pub struct BarkChannelConfig {
1131    #[serde(default)]
1132    pub enabled: bool,
1133    /// Bark server base URL (public api.day.app or a self-hosted instance).
1134    #[serde(default = "default_bark_base_url")]
1135    pub base_url: String,
1136    /// Bark device key identifying the target iOS device.
1137    ///
1138    /// Secret plaintext hydrated in memory from the isolated credential store;
1139    /// never serialized in ordinary config.
1140    #[serde(default, skip_serializing)]
1141    pub device_key: Option<String>,
1142    /// Legacy encrypted ciphertext accepted only for migration.
1143    #[serde(default, skip_serializing)]
1144    pub device_key_encrypted: Option<String>,
1145    /// Stable isolated credential-store reference.
1146    #[serde(default, skip_serializing_if = "Option::is_none")]
1147    pub credential_ref: Option<crate::CredentialRef>,
1148    /// Whether the referenced credential is expected to exist.
1149    #[serde(default)]
1150    pub configured: bool,
1151}
1152
1153impl Default for BarkChannelConfig {
1154    fn default() -> Self {
1155        Self {
1156            enabled: false,
1157            base_url: default_bark_base_url(),
1158            device_key: None,
1159            device_key_encrypted: None,
1160            credential_ref: None,
1161            configured: false,
1162        }
1163    }
1164}
1165
1166fn default_bark_base_url() -> String {
1167    "https://api.day.app".to_string()
1168}
1169
1170/// Notification delivery channels: native desktop plus push-relay services.
1171///
1172/// Additive/back-compat: an absent `notifications` key in `config.json`
1173/// deserializes to the defaults (desktop auto, ntfy/bark disabled).
1174#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1175pub struct NotificationsConfig {
1176    #[serde(default)]
1177    pub desktop: DesktopChannelConfig,
1178    #[serde(default)]
1179    pub ntfy: NtfyChannelConfig,
1180    #[serde(default)]
1181    pub bark: BarkChannelConfig,
1182}
1183
1184/// One IM-platform bridge configured under `[[connect.platforms]]` —
1185/// bamboo-connect (issue #452 / epic #447): drives a bamboo session from an
1186/// external chat platform (Telegram first).
1187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1188pub struct ConnectPlatformConfig {
1189    /// Stable per-entry identifier (#496). Not part of the original schema —
1190    /// absent on legacy/hand-written entries and on a freshly-echoed new
1191    /// entry from a client. [`Config::save_to_dir`] assigns one (a random
1192    /// UUID) to any entry that lacks it as part of the normal save path
1193    /// (migration-on-write); load never mutates/rewrites the config to
1194    /// backfill it, per #493's never-overwrite-until-confirmed semantics.
1195    ///
1196    /// Used by [`crate::patch::preserve_masked_connect_secrets`] as the
1197    /// FIRST resolution strategy for a masked secret in a settings PATCH,
1198    /// ahead of the positional/`type`-based fallbacks (#490/#492) — an exact
1199    /// id match unambiguously identifies the same logical entry even when
1200    /// two entries share the same `platform_type` and have been reordered,
1201    /// which position+type alone cannot always disambiguate.
1202    #[serde(default, skip_serializing_if = "Option::is_none")]
1203    pub id: Option<String>,
1204    /// Optional first-class Project id assigned to newly-created sessions from
1205    /// this connector. Existing connector sessions keep their persisted
1206    /// membership when configuration changes.
1207    #[serde(default, skip_serializing_if = "Option::is_none")]
1208    pub project_id: Option<bamboo_domain::ProjectId>,
1209    /// Platform adapter selector, e.g. `"telegram"`. Unrecognized values are
1210    /// skipped (with a startup warning) rather than failing config load —
1211    /// forward-compatible with future adapters (Feishu/Slack).
1212    #[serde(rename = "type")]
1213    pub platform_type: String,
1214    /// Platform bot/API token.
1215    ///
1216    /// Secret: encrypted at rest in `token_encrypted`; this plaintext field is
1217    /// never serialized and is hydrated in memory on load (mirrors
1218    /// [`NtfyChannelConfig::token`] / [`BarkChannelConfig::device_key`]).
1219    #[serde(default, skip_serializing)]
1220    pub token: Option<String>,
1221    /// Encrypted ciphertext of `token` (the at-rest representation).
1222    #[serde(default, skip_serializing_if = "Option::is_none")]
1223    pub token_encrypted: Option<String>,
1224    /// Stable reference to the isolated token credential.
1225    #[serde(default, skip_serializing_if = "Option::is_none")]
1226    pub token_credential_ref: Option<crate::CredentialRef>,
1227    /// Durable metadata used by redacted clients without exposing a value.
1228    #[serde(default)]
1229    pub token_configured: bool,
1230    /// Platform app id (Feishu `app_id`). Not a secret — serialized normally.
1231    /// Unused by the Telegram adapter.
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    pub app_id: Option<String>,
1234    /// Platform app secret (Feishu `app_secret`).
1235    ///
1236    /// Secret: encrypted at rest in `app_secret_encrypted`; this plaintext
1237    /// field is never serialized and is hydrated in memory on load (mirrors
1238    /// `token` above).
1239    #[serde(default, skip_serializing)]
1240    pub app_secret: Option<String>,
1241    /// Encrypted ciphertext of `app_secret` (the at-rest representation).
1242    #[serde(default, skip_serializing_if = "Option::is_none")]
1243    pub app_secret_encrypted: Option<String>,
1244    /// Stable reference to the isolated app-secret credential.
1245    #[serde(default, skip_serializing_if = "Option::is_none")]
1246    pub app_secret_credential_ref: Option<crate::CredentialRef>,
1247    /// Durable metadata used by redacted clients without exposing a value.
1248    #[serde(default)]
1249    pub app_secret_configured: bool,
1250    /// Platform domain/base-URL selector (Feishu-only today). Not a secret —
1251    /// serialized normally. `None`/`"feishu"` -> open.feishu.cn, `"lark"` ->
1252    /// open.larksuite.com, an `https://` value -> a private-deployment base
1253    /// URL used verbatim. Validation happens in the server registration arm,
1254    /// not here.
1255    #[serde(default, skip_serializing_if = "Option::is_none")]
1256    pub domain: Option<String>,
1257    /// Platform-scoped user ids allowed to drive a session. Deliberately
1258    /// STRICTER than the general secret-mask precedents: an EMPTY list means
1259    /// deny-all (every inbound message is rejected), not allow-all — a
1260    /// startup warning is logged when a platform has no allowed users.
1261    #[serde(default)]
1262    pub allow_from: Vec<String>,
1263    /// User ids allowed to run privileged/admin commands. Parsed from day one
1264    /// but UNUSED in the MVP (#452) — no admin commands exist yet; reserved
1265    /// for the approvals/admin phase of epic #447.
1266    #[serde(default)]
1267    pub admin_from: Vec<String>,
1268}
1269
1270/// bamboo-connect platform bridges: drive bamboo sessions from IM platforms.
1271/// Additive/back-compat: an absent `connect` key in `config.json`
1272/// deserializes to an empty platform list — fully inert (#452).
1273#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1274pub struct ConnectConfig {
1275    #[serde(default)]
1276    pub platforms: Vec<ConnectPlatformConfig>,
1277}
1278
1279fn connect_config_is_empty(config: &ConnectConfig) -> bool {
1280    config.platforms.is_empty()
1281}
1282
1283/// One publisher key trusted to sign plugin bundles.
1284///
1285/// `algorithm` is a plain string (not an enum) so an unrecognized future value
1286/// in an old/new config just never matches during verification rather than
1287/// failing to deserialize — additive/forward-compatible, matching this
1288/// crate's other config sections. Only `"ed25519"` is understood today.
1289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1290pub struct TrustedKey {
1291    /// Human-readable label (surfaced in logs/CLI output); purely descriptive.
1292    pub label: String,
1293    /// Signature algorithm. Only `"ed25519"` is currently verified.
1294    pub algorithm: String,
1295    /// Hex-encoded public key (32 raw bytes for ed25519).
1296    pub public_key: String,
1297}
1298
1299/// Raw Ed25519 public anchor committed by the Jiandu plugin publisher.
1300pub const JIANDU_PLUGIN_PUBLIC_KEY: &str =
1301    "bba9d0e5553401125f939bc3d844e891706d579a611fea9215545771d5306676";
1302
1303/// The official plugin-signing keys trusted by default, so an out-of-the-box
1304/// `bamboo plugin install <official release url>` needs no `--allow-unsigned`
1305/// for a bundle those repos' release CI signed. One entry per first-party
1306/// plugin publisher; each repo commits its public half as
1307/// `packaging/plugin/signing-key.pub` (nova) / `plugin/signing-key.pub`
1308/// (magpie and jiandu) for cross-checking.
1309fn default_trusted_keys() -> Vec<TrustedKey> {
1310    vec![
1311        TrustedKey {
1312            label: "jiandu (bigduu official)".to_string(),
1313            algorithm: "ed25519".to_string(),
1314            public_key: JIANDU_PLUGIN_PUBLIC_KEY.to_string(),
1315        },
1316        TrustedKey {
1317            label: "nova (bigduu official)".to_string(),
1318            algorithm: "ed25519".to_string(),
1319            public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
1320                .to_string(),
1321        },
1322        TrustedKey {
1323            label: "magpie (bigduu official)".to_string(),
1324            algorithm: "ed25519".to_string(),
1325            public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
1326                .to_string(),
1327        },
1328    ]
1329}
1330
1331/// Default trusted host+path prefix: the `bigduu` GitHub org/user's own repos
1332/// (e.g. `github.com/bigduu/Nova/releases/...`).
1333fn default_trusted_hosts() -> Vec<String> {
1334    vec!["github.com/bigduu/".to_string()]
1335}
1336
1337/// Plugin URL-install source-trust policy: a host allowlist (is the SOURCE
1338/// authorized?) plus ed25519 publisher keys (is the PUBLISHER authentic?).
1339/// This stacks on top of the checksum layer already enforced in
1340/// `bamboo_plugin::registry::PluginSource::Url` (are the BYTES what the
1341/// caller expected?) — see `bamboo-server`'s `plugin_source.rs` for where all
1342/// three layers are enforced together. A pasted checksum alone cannot
1343/// establish source trust (an attacker who controls the page a checksum was
1344/// copied from can just publish a checksum for their own tampered bundle);
1345/// the host allowlist and signature checks close that gap.
1346///
1347/// Both fields are user-editable (`config.json`, or the config-set HTTP/CLI
1348/// path) so an operator can add their own trusted hosts/keys. Additive/
1349/// back-compat: an absent `plugin_trust` key deserializes to
1350/// [`PluginTrustConfig::default`] (the built-in defaults below), not an empty
1351/// policy — so a fresh install can trust the official nova plugin out of the
1352/// box.
1353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1354pub struct PluginTrustConfig {
1355    /// Host+path prefixes a `url` plugin source may be fetched from without
1356    /// `--allow-untrusted-host`, e.g. `"github.com/bigduu/"` (a bare host
1357    /// with no `/`, e.g. `"example.com"`, matches any path on that exact
1358    /// host). Each entry is split into a host component and a path-prefix
1359    /// component and matched on PARSED URL components — whole-host equality
1360    /// plus a `/`-boundary path-prefix check, never a raw string
1361    /// `starts_with` — see [`is_host_trusted`] for the precise rule and why
1362    /// (it closes a domain-gluing bypass like `example.com` matching
1363    /// `example.com.evil.com`, and a sibling-path bypass like
1364    /// `github.com/bigduu` matching `github.com/bigduu-evil/x`).
1365    #[serde(default = "default_trusted_hosts")]
1366    pub trusted_hosts: Vec<String>,
1367    /// Publisher keys a bundle's `.sig` signature may verify against without
1368    /// `--allow-unsigned`.
1369    #[serde(default = "default_trusted_keys")]
1370    pub trusted_keys: Vec<TrustedKey>,
1371    /// Persistent, config-level escape hatch for the whole three-layer
1372    /// policy above: `Off` makes every `url` plugin install/update behave as
1373    /// if `--insecure` (equivalently, `--allow-untrusted-host
1374    /// --allow-unsigned --allow-unverified`) were passed, WITHOUT needing the
1375    /// per-install flag every time — the "I run a private/dev bamboo and
1376    /// don't want to pass flags on every install" customization. Defaults to
1377    /// [`PluginTrustEnforcement::Strict`] — a fresh config, or one with no
1378    /// `plugin_trust.enforcement` key at all, is secure by default; relaxing
1379    /// it is always an explicit, user-initiated edit
1380    /// (`bamboo config set plugin_trust.enforcement off`), never a silent
1381    /// weakening. See `bamboo-server`'s `plugin_source.rs` for where this is
1382    /// enforced, and `AppState::new` for the loud startup warning emitted
1383    /// whenever a server boots with this set to `Off`.
1384    #[serde(default)]
1385    pub enforcement: PluginTrustEnforcement,
1386}
1387
1388impl Default for PluginTrustConfig {
1389    fn default() -> Self {
1390        Self {
1391            trusted_hosts: default_trusted_hosts(),
1392            trusted_keys: default_trusted_keys(),
1393            enforcement: PluginTrustEnforcement::default(),
1394        }
1395    }
1396}
1397
1398impl PluginTrustConfig {
1399    /// True when `url` is `https` and its host+path match one of
1400    /// `trusted_hosts` on parsed URL components (host compared
1401    /// case-insensitively as a WHOLE string, path matched on a `/` boundary
1402    /// — see the free function [`is_host_trusted`] for the precise rule; an
1403    /// unparseable URL or a non-`https` scheme is never trusted).
1404    pub fn is_host_trusted(&self, url: &str) -> bool {
1405        is_host_trusted(url, &self.trusted_hosts)
1406    }
1407
1408    /// True when `enforcement` is [`PluginTrustEnforcement::Off`] — every
1409    /// `url` plugin install/update should skip the host allowlist, signature,
1410    /// and checksum-requirement layers, exactly as if `--insecure` were
1411    /// passed to that individual install. See the field's doc comment for
1412    /// the full rationale.
1413    pub fn enforcement_is_off(&self) -> bool {
1414        matches!(self.enforcement, PluginTrustEnforcement::Off)
1415    }
1416}
1417
1418/// `plugin_trust.enforcement`: the persistent, config-level form of the
1419/// `--insecure` escape hatch (see [`PluginTrustConfig::enforcement`]).
1420///
1421/// Deserialization accepts either the canonical string form (`"strict"` /
1422/// `"off"`, case-insensitive) or a bool-ish alias (`true` == `Strict`,
1423/// `false` == `Off`) for a hand-edited `config.json` — `true`/`false` read
1424/// naturally as "is enforcement on?". The string form is what
1425/// `bamboo config set plugin_trust.enforcement off` writes (and the only
1426/// form the generic dot-path setter's round-trip check accepts on write,
1427/// since this type always *serializes* back out as a string — see
1428/// `bamboo-config`'s `dot_path` module); the bool alias is a read-side
1429/// convenience for whoever edits `config.json` directly.
1430#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1431#[serde(rename_all = "snake_case")]
1432pub enum PluginTrustEnforcement {
1433    /// Secure by default: the host allowlist, signature, and checksum layers
1434    /// are all enforced (each individually waivable via
1435    /// `--allow-untrusted-host` / `--allow-unsigned` / `--allow-unverified`,
1436    /// or all at once via `--insecure`).
1437    #[default]
1438    Strict,
1439    /// Every `url` plugin install/update skips all three trust layers,
1440    /// without needing any per-install flag. Opt-in only — never the
1441    /// default for a fresh or pre-existing config.
1442    Off,
1443}
1444
1445impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1446    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1447    where
1448        D: serde::Deserializer<'de>,
1449    {
1450        #[derive(Deserialize)]
1451        #[serde(untagged)]
1452        enum Repr {
1453            Bool(bool),
1454            Str(String),
1455        }
1456        match Repr::deserialize(deserializer)? {
1457            Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1458            Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1459            Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1460                "strict" => Ok(PluginTrustEnforcement::Strict),
1461                "off" => Ok(PluginTrustEnforcement::Off),
1462                other => Err(serde::de::Error::custom(format!(
1463                    "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1464                     \"off\""
1465                ))),
1466            },
1467        }
1468    }
1469}
1470
1471/// One `trusted_hosts` entry, split into its host and path-prefix
1472/// components (see [`is_host_trusted`]). `path_prefix` is empty for a
1473/// bare-host entry (e.g. `"example.com"`, meaning "any path on this exact
1474/// host") or starts with `/` (e.g. `"/bigduu/"` from `"github.com/bigduu/"`).
1475struct TrustedHostEntry<'a> {
1476    host: &'a str,
1477    path_prefix: &'a str,
1478}
1479
1480/// Split a raw `trusted_hosts` entry at its first `/` into host + path
1481/// components. Entries are compared against ALREADY-lowercased input by the
1482/// caller ([`is_host_trusted`]), so this does no case normalization itself.
1483fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1484    match entry.find('/') {
1485        Some(index) => TrustedHostEntry {
1486            host: &entry[..index],
1487            path_prefix: &entry[index..],
1488        },
1489        None => TrustedHostEntry {
1490            host: entry,
1491            path_prefix: "",
1492        },
1493    }
1494}
1495
1496/// True when `path` matches `prefix` on a `/` path-component boundary, never
1497/// on a raw byte prefix: exactly equal to `prefix`, or `prefix` ends in `/`
1498/// and `path` starts with it, or the character in `path` immediately
1499/// following `prefix` is `/`. An empty `prefix` (a bare-host trusted_hosts
1500/// entry) matches any path.
1501///
1502/// This is what stops a sibling path from passing as a prefix match — e.g.
1503/// entry `github.com/bigduu` (no trailing slash) must NOT match
1504/// `github.com/bigduu-evil/x`: `"/bigduu-evil/x"` starts with `"/bigduu"` as
1505/// raw bytes, but the character right after the prefix is `-`, not `/`, so
1506/// this correctly refuses it.
1507fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1508    if prefix.is_empty() || path == prefix {
1509        return true;
1510    }
1511    if prefix.ends_with('/') {
1512        return path.starts_with(prefix);
1513    }
1514    path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1515}
1516
1517/// Free function backing [`PluginTrustConfig::is_host_trusted`] — exposed
1518/// separately so callers (and tests) can check a candidate host list without
1519/// constructing a full [`PluginTrustConfig`]/[`Config`].
1520///
1521/// Matches on PARSED URL COMPONENTS, not a raw string prefix: `url` must be
1522/// `https`, its `host_str()` (already correct for userinfo — `user@host` or
1523/// `host@evil.com`-style tricks resolve to the real host, not a decoy — and
1524/// for an explicit port, which `host_str()` excludes) must EQUAL a
1525/// `trusted_hosts` entry's host component (case-insensitively, WHOLE host —
1526/// never a `starts_with`), and its (already dot-segment-normalized by
1527/// `Url::parse`) path must match that entry's path-prefix component on a `/`
1528/// boundary (see [`path_matches_prefix`]). A bare-host entry (no `/` in it)
1529/// has an empty path-prefix, so it matches any path but ONLY on that exact
1530/// host.
1531///
1532/// A prior raw-`starts_with` implementation was defeated by (1) gluing a
1533/// trusted bare host into a longer attacker-controlled one, e.g.
1534/// `trusted.example.com` matching `trusted.example.com.evil.com` /
1535/// `trusted.example.comevil.com`, and (2) a sibling path prefix, e.g.
1536/// `github.com/bigduu` (no trailing slash) matching
1537/// `github.com/bigduu-evil/x`. Component-wise matching closes both: host
1538/// comparison is whole-string equality (no gluing possible), and the path
1539/// check enforces a `/` boundary (no sibling-prefix bypass possible).
1540pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1541    let Ok(parsed) = url::Url::parse(url) else {
1542        return false;
1543    };
1544    if parsed.scheme() != "https" {
1545        return false;
1546    }
1547    let Some(host) = parsed.host_str() else {
1548        return false;
1549    };
1550    let host = host.to_ascii_lowercase();
1551    let path = parsed.path();
1552
1553    trusted_hosts.iter().any(|raw_entry| {
1554        let entry = raw_entry.trim().to_ascii_lowercase();
1555        let parsed_entry = parse_trusted_host_entry(&entry);
1556        host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1557    })
1558}
1559
1560/// Host strategy used when the active model context approaches its input limit.
1561///
1562/// `Summary` remains the compatibility default. `MessageSummary` summarizes only
1563/// historical user/assistant prose and removes completed tool exchanges from the
1564/// active model view. `RetrievalWindow` is opt-in and
1565/// archives old exact messages only after the runtime has verified that the
1566/// current-session history capability is callable and the boundary can be
1567/// durably checkpointed.
1568#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1569#[serde(rename_all = "snake_case")]
1570pub enum ContextManagementStrategy {
1571    #[default]
1572    Summary,
1573    MessageSummary,
1574    RetrievalWindow,
1575}
1576
1577/// Explicit fallback used when retrieval-window cannot satisfy a runtime
1578/// precondition. There is deliberately no implicit summary fallback.
1579#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1580#[serde(rename_all = "snake_case")]
1581pub enum ContextManagementFallbackStrategy {
1582    #[default]
1583    None,
1584    Summary,
1585}
1586
1587fn default_retrieval_window_min_recent_user_turns() -> usize {
1588    3
1589}
1590
1591fn default_retrieval_window_trigger_usage_ratio() -> f64 {
1592    0.80
1593}
1594
1595fn default_retrieval_window_target_usage_ratio() -> f64 {
1596    0.60
1597}
1598
1599fn default_history_tool_required() -> bool {
1600    true
1601}
1602
1603/// Selection and safety policy for the opt-in retrieval-window strategy.
1604#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1605#[serde(default)]
1606pub struct RetrievalWindowContextConfig {
1607    #[serde(default = "default_retrieval_window_min_recent_user_turns")]
1608    pub min_recent_user_turns: usize,
1609    #[serde(default = "default_retrieval_window_trigger_usage_ratio")]
1610    pub trigger_usage_ratio: f64,
1611    #[serde(default = "default_retrieval_window_target_usage_ratio")]
1612    pub target_usage_ratio: f64,
1613    #[serde(default = "default_history_tool_required")]
1614    pub history_tool_required: bool,
1615    #[serde(default)]
1616    pub fallback_strategy: ContextManagementFallbackStrategy,
1617}
1618
1619impl Default for RetrievalWindowContextConfig {
1620    fn default() -> Self {
1621        Self {
1622            min_recent_user_turns: default_retrieval_window_min_recent_user_turns(),
1623            trigger_usage_ratio: default_retrieval_window_trigger_usage_ratio(),
1624            target_usage_ratio: default_retrieval_window_target_usage_ratio(),
1625            history_tool_required: default_history_tool_required(),
1626            fallback_strategy: ContextManagementFallbackStrategy::None,
1627        }
1628    }
1629}
1630
1631/// Backward-compatible context-management configuration snapshot.
1632#[derive(Debug, Clone, Serialize, PartialEq)]
1633pub struct ContextManagementConfig {
1634    pub strategy: ContextManagementStrategy,
1635    pub retrieval_window: RetrievalWindowContextConfig,
1636}
1637
1638impl Default for ContextManagementConfig {
1639    fn default() -> Self {
1640        Self {
1641            strategy: ContextManagementStrategy::Summary,
1642            retrieval_window: RetrievalWindowContextConfig::default(),
1643        }
1644    }
1645}
1646
1647impl ContextManagementConfig {
1648    pub fn is_default(&self) -> bool {
1649        *self == Self::default()
1650    }
1651
1652    pub fn validate(&self) -> std::result::Result<(), String> {
1653        let policy = &self.retrieval_window;
1654        if policy.min_recent_user_turns == 0 {
1655            return Err(
1656                "context_management.retrieval_window.min_recent_user_turns must be greater than zero"
1657                    .to_string(),
1658            );
1659        }
1660        if !policy.target_usage_ratio.is_finite()
1661            || !policy.trigger_usage_ratio.is_finite()
1662            || policy.target_usage_ratio < 0.01
1663            || policy.target_usage_ratio >= policy.trigger_usage_ratio
1664            || policy.trigger_usage_ratio > 1.0
1665        {
1666            return Err(
1667                "context_management retrieval ratios must satisfy 0.01 <= target_usage_ratio < trigger_usage_ratio <= 1"
1668                    .to_string(),
1669            );
1670        }
1671        if self.strategy == ContextManagementStrategy::RetrievalWindow
1672            && !policy.history_tool_required
1673        {
1674            return Err(
1675                "retrieval_window requires history_tool_required=true in this release".to_string(),
1676            );
1677        }
1678        Ok(())
1679    }
1680
1681    /// Planner policy is intentionally percent-based in the domain primitive.
1682    /// Flooring keeps fractional configuration conservative rather than
1683    /// archiving less history than the configured target permits. The planner's
1684    /// minimum representable positive target is one percent.
1685    pub fn retrieval_target_usage_percent(&self) -> u8 {
1686        ((self.retrieval_window.target_usage_ratio * 100.0).floor() as u8).max(1)
1687    }
1688}
1689
1690impl<'de> Deserialize<'de> for ContextManagementConfig {
1691    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1692    where
1693        D: serde::Deserializer<'de>,
1694    {
1695        #[derive(Deserialize)]
1696        #[serde(default)]
1697        struct Wire {
1698            strategy: ContextManagementStrategy,
1699            retrieval_window: RetrievalWindowContextConfig,
1700        }
1701
1702        impl Default for Wire {
1703            fn default() -> Self {
1704                let defaults = ContextManagementConfig::default();
1705                Self {
1706                    strategy: defaults.strategy,
1707                    retrieval_window: defaults.retrieval_window,
1708                }
1709            }
1710        }
1711
1712        let wire = Wire::deserialize(deserializer)?;
1713        let config = Self {
1714            strategy: wire.strategy,
1715            retrieval_window: wire.retrieval_window,
1716        };
1717        config.validate().map_err(serde::de::Error::custom)?;
1718        Ok(config)
1719    }
1720}
1721
1722/// Main configuration structure for Bamboo agent
1723///
1724/// Contains all settings needed to run the agent, including provider credentials,
1725/// proxy settings, model selection, and server configuration.
1726#[derive(Debug, Clone, Serialize, Deserialize)]
1727#[doc(hidden)]
1728pub struct ConfigValues {
1729    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
1730    #[serde(default)]
1731    pub http_proxy: String,
1732    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
1733    #[serde(default)]
1734    pub https_proxy: String,
1735    /// Proxy authentication credentials
1736    ///
1737    /// Kept in memory only; ordinary config stores `proxy_auth_credential_ref`.
1738    #[serde(skip_serializing)]
1739    pub proxy_auth: Option<ProxyAuth>,
1740    /// Legacy encrypted proxy authentication accepted only for migration.
1741    #[serde(default, skip_serializing_if = "Option::is_none")]
1742    pub proxy_auth_encrypted: Option<String>,
1743    /// Stable credential-store reference for proxy authentication.
1744    #[serde(default, skip_serializing_if = "Option::is_none")]
1745    pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1746    /// Deprecated: Use `providers.copilot.headless_auth` instead
1747    #[serde(default)]
1748    pub headless_auth: bool,
1749
1750    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
1751    #[serde(default = "default_provider")]
1752    pub provider: String,
1753
1754    /// Default model assignments (used when features.provider_model_ref is enabled).
1755    #[serde(default, skip_serializing_if = "Option::is_none")]
1756    pub defaults: Option<DefaultsConfig>,
1757
1758    /// Multi-instance provider configurations keyed by instance id.
1759    ///
1760    /// When `provider_instances` is non-empty, the registry and router prefer
1761    /// instance ids as routing keys. Legacy `providers` / `provider` fields are
1762    /// still supported for backward compatibility; see
1763    /// [`Config::synthesize_legacy_instances`].
1764    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1765    pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1766
1767    /// The default provider instance id used when a request does not specify one.
1768    ///
1769    /// When set, this takes precedence over the legacy `provider` field.
1770    #[serde(default, skip_serializing_if = "Option::is_none")]
1771    pub default_provider_instance: Option<String>,
1772
1773    /// HTTP server configuration
1774    #[serde(default)]
1775    pub server: ServerConfig,
1776
1777    /// Global keyword masking configuration.
1778    ///
1779    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
1780    #[serde(default)]
1781    pub keyword_masking: KeywordMaskingConfig,
1782
1783    /// Anthropic model mapping configuration.
1784    ///
1785    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
1786    #[serde(default)]
1787    pub anthropic_model_mapping: AnthropicModelMapping,
1788
1789    /// Gemini model mapping configuration.
1790    ///
1791    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
1792    #[serde(default)]
1793    pub gemini_model_mapping: GeminiModelMapping,
1794
1795    /// Request preflight hooks.
1796    ///
1797    /// These hooks can inspect and rewrite outgoing requests before they are sent upstream
1798    /// (e.g. image fallback behavior for text-only models).
1799    #[serde(default)]
1800    pub hooks: HooksConfig,
1801
1802    /// User-configured agent lifecycle command or external script handlers.
1803    ///
1804    /// This is intentionally separate from `hooks`, which is already the
1805    /// provider HTTP request-hook namespace. Lifecycle hooks are snapshotted
1806    /// when an agent run starts.
1807    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1808    pub lifecycle_hooks: LifecycleHooksConfig,
1809
1810    /// Global tool toggles.
1811    ///
1812    /// Any tool listed in `disabled` is omitted from the tool schemas sent to the LLM.
1813    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1814    pub tools: ToolsConfig,
1815
1816    /// Global skill toggles.
1817    ///
1818    /// Any skill listed in `disabled` is excluded from skill context construction and
1819    /// cannot be loaded through the skill runtime tools.
1820    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1821    pub skills: SkillsConfig,
1822
1823    /// User-managed environment variables injected into Bash tool processes.
1824    ///
1825    /// Secret entries are encrypted at rest; plaintext values are hydrated in memory.
1826    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1827    pub env_vars: Vec<EnvVarEntry>,
1828
1829    /// Default work area used when a session has no explicit active workspace.
1830    #[serde(default, skip_serializing_if = "Option::is_none")]
1831    pub default_work_area: Option<DefaultWorkAreaConfig>,
1832
1833    #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
1834    pub worktrees: WorktreeSettings,
1835
1836    /// Access control / password gate configuration.
1837    #[serde(default, skip_serializing_if = "Option::is_none")]
1838    pub access_control: Option<AccessControlConfig>,
1839
1840    /// Feature flags for incremental rollout.
1841    #[serde(default)]
1842    pub features: FeatureFlags,
1843
1844    /// Config-level default per-run token/tool-call/subagent budget (issue
1845    /// #221). `None` fields are unlimited. A per-request `ExecuteRequest`
1846    /// override may only tighten these ceilings, never loosen them; see
1847    /// [`RunBudgetConfig::merged_with_override`].
1848    #[serde(default)]
1849    pub run_budget: RunBudgetConfig,
1850
1851    /// LLM stream liveness and semantic-progress watchdog policy.
1852    #[serde(default)]
1853    pub stream_timeout: StreamTimeoutConfig,
1854
1855    /// Host-owned strategy for bounding provider-visible conversation context.
1856    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
1857    pub context_management: ContextManagementConfig,
1858
1859    /// Remote Cluster Fabric: operator-managed nodes & clusters for deploying
1860    /// `broker-agent` workers locally or over SSH. Additive/back-compat: absent
1861    /// ⇒ empty. SSH secrets are encrypted at rest (see [`crate::cluster_fabric`]).
1862    #[serde(
1863        default,
1864        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1865    )]
1866    pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1867
1868    /// MCP server configuration.
1869    ///
1870    /// Previously persisted in `mcp.json` (now unified into `config.json`).
1871    // On disk we use the mainstream `mcpServers` key (matching Claude Desktop / MCP ecosystem
1872    // conventions). We still accept the legacy `mcp` key for backward compatibility.
1873    #[serde(default, rename = "mcpServers", alias = "mcp")]
1874    pub mcp: bamboo_domain::mcp_config::McpConfig,
1875
1876    /// Notification delivery channels (desktop + push-relay services).
1877    /// Secrets (ntfy token, Bark device key) live in the isolated credential
1878    /// store; only stable references/configured metadata persist here.
1879    #[serde(default)]
1880    pub notifications: NotificationsConfig,
1881
1882    /// bamboo-connect IM-platform bridges (Telegram first, #452 / epic #447).
1883    /// Secrets (each platform's `token`) are encrypted at rest — see
1884    /// [`Config::hydrate_connect_platform_tokens_from_encrypted`] /
1885    /// [`Config::refresh_connect_platform_tokens_encrypted`].
1886    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1887    pub connect: ConnectConfig,
1888
1889    /// Plugin URL-install source-trust policy (host allowlist + ed25519
1890    /// publisher keys). See [`PluginTrustConfig`]'s docs for the three-layer
1891    /// model this stacks with the checksum layer.
1892    #[serde(default)]
1893    pub plugin_trust: PluginTrustConfig,
1894
1895    /// Extension fields stored at the root of `config.json`.
1896    ///
1897    /// This keeps the config forward-compatible and allows unrelated subsystems
1898    /// (e.g. setup UI state) to persist their own keys without getting dropped by
1899    /// typed (de)serialization.
1900    #[serde(default, flatten)]
1901    pub extra: BTreeMap<String, Value>,
1902}
1903
1904impl Default for ConfigValues {
1905    fn default() -> Self {
1906        Self {
1907            http_proxy: String::new(),
1908            https_proxy: String::new(),
1909            proxy_auth: None,
1910            proxy_auth_encrypted: None,
1911            proxy_auth_credential_ref: None,
1912            headless_auth: false,
1913            run_budget: RunBudgetConfig::default(),
1914            stream_timeout: StreamTimeoutConfig::default(),
1915            context_management: ContextManagementConfig::default(),
1916            cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
1917            provider: default_provider(),
1918            provider_instances: HashMap::new(),
1919            default_provider_instance: None,
1920            server: ServerConfig::default(),
1921            keyword_masking: KeywordMaskingConfig::default(),
1922            anthropic_model_mapping: AnthropicModelMapping::default(),
1923            gemini_model_mapping: GeminiModelMapping::default(),
1924            hooks: HooksConfig::default(),
1925            lifecycle_hooks: LifecycleHooksConfig::default(),
1926            tools: ToolsConfig::default(),
1927            skills: SkillsConfig::default(),
1928            env_vars: Vec::new(),
1929            default_work_area: None,
1930            worktrees: WorktreeSettings::default(),
1931            access_control: None,
1932            features: FeatureFlags::default(),
1933            defaults: None,
1934            mcp: bamboo_domain::mcp_config::McpConfig::default(),
1935            notifications: NotificationsConfig::default(),
1936            connect: ConnectConfig::default(),
1937            plugin_trust: PluginTrustConfig::default(),
1938            extra: BTreeMap::new(),
1939        }
1940    }
1941}
1942
1943/// Network-facing root configuration. Flattening preserves the historical
1944/// top-level JSON keys while making the persisted root structurally modular.
1945#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1946struct NetworkConfigSection {
1947    #[serde(default)]
1948    http_proxy: String,
1949    #[serde(default)]
1950    https_proxy: String,
1951    #[serde(skip_serializing)]
1952    proxy_auth: Option<ProxyAuth>,
1953    #[serde(default, skip_serializing_if = "Option::is_none")]
1954    proxy_auth_encrypted: Option<String>,
1955    #[serde(default, skip_serializing_if = "Option::is_none")]
1956    proxy_auth_credential_ref: Option<crate::CredentialRef>,
1957    #[serde(default)]
1958    headless_auth: bool,
1959    #[serde(default)]
1960    server: ServerConfig,
1961}
1962
1963#[derive(Debug, Clone, Serialize, Deserialize)]
1964struct ProviderRoutingConfigSection {
1965    #[serde(default = "default_provider")]
1966    provider: String,
1967    #[serde(default, skip_serializing_if = "Option::is_none")]
1968    defaults: Option<DefaultsConfig>,
1969    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1970    provider_instances: HashMap<String, ProviderInstanceConfig>,
1971    #[serde(default, skip_serializing_if = "Option::is_none")]
1972    default_provider_instance: Option<String>,
1973}
1974
1975impl Default for ProviderRoutingConfigSection {
1976    fn default() -> Self {
1977        Self {
1978            provider: default_provider(),
1979            defaults: None,
1980            provider_instances: HashMap::new(),
1981            default_provider_instance: None,
1982        }
1983    }
1984}
1985
1986#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1987struct ModelBehaviorConfigSection {
1988    #[serde(default)]
1989    keyword_masking: KeywordMaskingConfig,
1990    #[serde(default)]
1991    anthropic_model_mapping: AnthropicModelMapping,
1992    #[serde(default)]
1993    gemini_model_mapping: GeminiModelMapping,
1994    #[serde(default)]
1995    hooks: HooksConfig,
1996    #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1997    lifecycle_hooks: LifecycleHooksConfig,
1998}
1999
2000#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2001struct ToolingConfigSection {
2002    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
2003    tools: ToolsConfig,
2004    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
2005    skills: SkillsConfig,
2006    #[serde(default, rename = "mcpServers", alias = "mcp")]
2007    mcp: bamboo_domain::mcp_config::McpConfig,
2008}
2009
2010#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2011struct WorkspaceConfigSection {
2012    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2013    env_vars: Vec<EnvVarEntry>,
2014    #[serde(default, skip_serializing_if = "Option::is_none")]
2015    default_work_area: Option<DefaultWorkAreaConfig>,
2016    #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
2017    worktrees: WorktreeSettings,
2018    #[serde(default, skip_serializing_if = "Option::is_none")]
2019    access_control: Option<AccessControlConfig>,
2020}
2021
2022#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2023struct ExecutionConfigSection {
2024    #[serde(default)]
2025    features: FeatureFlags,
2026    #[serde(default)]
2027    run_budget: RunBudgetConfig,
2028    #[serde(default)]
2029    stream_timeout: StreamTimeoutConfig,
2030    #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2031    context_management: ContextManagementConfig,
2032    #[serde(
2033        default,
2034        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2035    )]
2036    cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2037}
2038
2039#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2040struct IntegrationConfigSection {
2041    #[serde(default)]
2042    notifications: NotificationsConfig,
2043    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2044    connect: ConnectConfig,
2045}
2046
2047#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2048struct PluginSecurityConfigSection {
2049    #[serde(default)]
2050    plugin_trust: PluginTrustConfig,
2051}
2052
2053// Count declarations from the same field list that defines each structural
2054// budgeted type, so the constants cannot drift from the actual structs.
2055macro_rules! count_fields {
2056    ($($field:ident),* $(,)?) => {
2057        <[()]>::len(&[$(count_fields!(@one $field)),*])
2058    };
2059    (@one $field:ident) => { () };
2060}
2061
2062macro_rules! define_counted_struct {
2063    (
2064        $(#[$struct_meta:meta])*
2065        $visibility:vis struct $name:ident {
2066            $(
2067                $(#[$field_meta:meta])*
2068                $field_visibility:vis $field:ident: $field_type:ty
2069            ),* $(,)?
2070        }
2071        count $count_visibility:vis $count_name:ident;
2072    ) => {
2073        $(#[$struct_meta])*
2074        $visibility struct $name {
2075            $(
2076                $(#[$field_meta])*
2077                $field_visibility $field: $field_type,
2078            )*
2079        }
2080
2081        $count_visibility const $count_name: usize = count_fields!($($field),*);
2082    };
2083}
2084
2085define_counted_struct! {
2086    /// The root-only persistence DTO written to `config.json`.
2087    ///
2088    /// Every section is flattened so existing documents keep their historical
2089    /// top-level shape. The structural field count is nevertheless nine rather
2090    /// than the Phase-#39 baseline of 31.
2091    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
2092    struct ConfigRoot {
2093        #[serde(flatten)]
2094        network: NetworkConfigSection,
2095        #[serde(flatten)]
2096        provider_routing: ProviderRoutingConfigSection,
2097        #[serde(flatten)]
2098        model_behavior: ModelBehaviorConfigSection,
2099        #[serde(flatten)]
2100        tooling: ToolingConfigSection,
2101        #[serde(flatten)]
2102        workspace: WorkspaceConfigSection,
2103        #[serde(flatten)]
2104        execution: ExecutionConfigSection,
2105        #[serde(flatten)]
2106        integrations: IntegrationConfigSection,
2107        #[serde(flatten)]
2108        plugin_security: PluginSecurityConfigSection,
2109        #[serde(default, flatten)]
2110        extra: BTreeMap<String, Value>,
2111    }
2112    count pub PERSISTED_ROOT_FIELD_COUNT;
2113}
2114
2115impl From<ConfigValues> for ConfigRoot {
2116    fn from(values: ConfigValues) -> Self {
2117        // Deliberately exhaustive: adding a runtime compatibility field must
2118        // update its persisted section mapping or this conversion stops compiling.
2119        let ConfigValues {
2120            http_proxy,
2121            https_proxy,
2122            proxy_auth,
2123            proxy_auth_encrypted,
2124            proxy_auth_credential_ref,
2125            headless_auth,
2126            provider,
2127            defaults,
2128            provider_instances,
2129            default_provider_instance,
2130            server,
2131            keyword_masking,
2132            anthropic_model_mapping,
2133            gemini_model_mapping,
2134            hooks,
2135            lifecycle_hooks,
2136            tools,
2137            skills,
2138            env_vars,
2139            default_work_area,
2140            worktrees,
2141            access_control,
2142            features,
2143            run_budget,
2144            stream_timeout,
2145            context_management,
2146            cluster_fabric,
2147            mcp,
2148            notifications,
2149            connect,
2150            plugin_trust,
2151            extra,
2152        } = values;
2153
2154        Self {
2155            network: NetworkConfigSection {
2156                http_proxy,
2157                https_proxy,
2158                proxy_auth,
2159                proxy_auth_encrypted,
2160                proxy_auth_credential_ref,
2161                headless_auth,
2162                server,
2163            },
2164            provider_routing: ProviderRoutingConfigSection {
2165                provider,
2166                defaults,
2167                provider_instances,
2168                default_provider_instance,
2169            },
2170            model_behavior: ModelBehaviorConfigSection {
2171                keyword_masking,
2172                anthropic_model_mapping,
2173                gemini_model_mapping,
2174                hooks,
2175                lifecycle_hooks,
2176            },
2177            tooling: ToolingConfigSection { tools, skills, mcp },
2178            workspace: WorkspaceConfigSection {
2179                env_vars,
2180                default_work_area,
2181                worktrees,
2182                access_control,
2183            },
2184            execution: ExecutionConfigSection {
2185                features,
2186                run_budget,
2187                stream_timeout,
2188                context_management,
2189                cluster_fabric,
2190            },
2191            integrations: IntegrationConfigSection {
2192                notifications,
2193                connect,
2194            },
2195            plugin_security: PluginSecurityConfigSection { plugin_trust },
2196            extra,
2197        }
2198    }
2199}
2200
2201impl From<ConfigRoot> for ConfigValues {
2202    fn from(root: ConfigRoot) -> Self {
2203        // Keep every root and section destructure exhaustive so a newly added
2204        // persisted field cannot be silently omitted from the runtime view.
2205        let ConfigRoot {
2206            network,
2207            provider_routing,
2208            model_behavior,
2209            tooling,
2210            workspace,
2211            execution,
2212            integrations,
2213            plugin_security,
2214            extra,
2215        } = root;
2216        let NetworkConfigSection {
2217            http_proxy,
2218            https_proxy,
2219            proxy_auth,
2220            proxy_auth_encrypted,
2221            proxy_auth_credential_ref,
2222            headless_auth,
2223            server,
2224        } = network;
2225        let ProviderRoutingConfigSection {
2226            provider,
2227            defaults,
2228            provider_instances,
2229            default_provider_instance,
2230        } = provider_routing;
2231        let ModelBehaviorConfigSection {
2232            keyword_masking,
2233            anthropic_model_mapping,
2234            gemini_model_mapping,
2235            hooks,
2236            lifecycle_hooks,
2237        } = model_behavior;
2238        let ToolingConfigSection { tools, skills, mcp } = tooling;
2239        let WorkspaceConfigSection {
2240            env_vars,
2241            default_work_area,
2242            worktrees,
2243            access_control,
2244        } = workspace;
2245        let ExecutionConfigSection {
2246            features,
2247            run_budget,
2248            stream_timeout,
2249            context_management,
2250            cluster_fabric,
2251        } = execution;
2252        let IntegrationConfigSection {
2253            notifications,
2254            connect,
2255        } = integrations;
2256        let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2257
2258        Self {
2259            http_proxy,
2260            https_proxy,
2261            proxy_auth,
2262            proxy_auth_encrypted,
2263            proxy_auth_credential_ref,
2264            headless_auth,
2265            provider,
2266            defaults,
2267            provider_instances,
2268            default_provider_instance,
2269            server,
2270            keyword_masking,
2271            anthropic_model_mapping,
2272            gemini_model_mapping,
2273            hooks,
2274            lifecycle_hooks,
2275            tools,
2276            skills,
2277            env_vars,
2278            default_work_area,
2279            worktrees,
2280            access_control,
2281            features,
2282            run_budget,
2283            stream_timeout,
2284            context_management,
2285            cluster_fabric,
2286            mcp,
2287            notifications,
2288            connect,
2289            plugin_trust,
2290            extra,
2291        }
2292    }
2293}
2294
2295/// Serialize the root-only durable document.
2296///
2297/// Public `Config` serialization intentionally retains the historical
2298/// compatibility shape, including the legacy `provider` selector. Durable
2299/// instance-native writes are narrower: the explicit instance default is the
2300/// routing authority, so legacy routing fields must not be written back.
2301fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2302    let instance_native = values
2303        .default_provider_instance
2304        .as_ref()
2305        .is_some_and(|id| values.provider_instances.contains_key(id));
2306    let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2307    if instance_native {
2308        if let Some(object) = value.as_object_mut() {
2309            object.remove("provider");
2310            object.remove("providers");
2311        }
2312    }
2313    Ok(value)
2314}
2315
2316define_counted_struct! {
2317    /// Runtime configuration facade. Phase-1 sidecar domains are typed modules;
2318    /// the remaining values keep field-access compatibility through `Deref`.
2319    #[derive(Debug, Clone)]
2320    pub struct Config {
2321        values: ConfigValues,
2322        pub(crate) memory: crate::MemoryConfigModule,
2323        pub(crate) subagents: crate::SubagentsConfigModule,
2324        pub(crate) providers: crate::ProviderConfigsModule,
2325        recovery_status: Option<ConfigRecoveryStatus>,
2326    }
2327    count pub CONFIG_FIELD_COUNT;
2328}
2329
2330/// Auditable structural budgets from Issue #590.
2331pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2332const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2333const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2334
2335impl std::ops::Deref for Config {
2336    type Target = ConfigValues;
2337
2338    fn deref(&self) -> &Self::Target {
2339        &self.values
2340    }
2341}
2342
2343impl std::ops::DerefMut for Config {
2344    fn deref_mut(&mut self) -> &mut Self::Target {
2345        &mut self.values
2346    }
2347}
2348
2349impl Serialize for Config {
2350    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2351    where
2352        S: serde::Serializer,
2353    {
2354        self.to_compatibility_value()
2355            .map_err(serde::ser::Error::custom)?
2356            .serialize(serializer)
2357    }
2358}
2359
2360impl<'de> Deserialize<'de> for Config {
2361    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2362    where
2363        D: serde::Deserializer<'de>,
2364    {
2365        use serde::de::Error;
2366
2367        let mut value = Value::deserialize(deserializer)?;
2368        let object = value
2369            .as_object_mut()
2370            .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2371        let memory = object
2372            .remove("memory")
2373            .map(serde_json::from_value)
2374            .transpose()
2375            .map_err(D::Error::custom)?
2376            .unwrap_or_default();
2377        let subagents = object
2378            .remove("subagents")
2379            .map(serde_json::from_value)
2380            .transpose()
2381            .map_err(D::Error::custom)?
2382            .unwrap_or_default();
2383        let providers = object
2384            .remove("providers")
2385            .map(serde_json::from_value)
2386            .transpose()
2387            .map_err(D::Error::custom)?
2388            .unwrap_or_default();
2389        let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2390
2391        Ok(Self::from_parts(root.into(), memory, subagents, providers))
2392    }
2393}
2394
2395/// Where a [`ConfigRecoveryStatus`]'s recovered values came from. #153.
2396#[derive(Debug, Clone, PartialEq, Serialize)]
2397#[serde(tag = "kind", rename_all = "snake_case")]
2398pub enum ConfigRecoverySource {
2399    /// Field-by-field salvage from the corrupt file itself
2400    /// ([`Config::salvage_partial`]); `fields` lists the top-level keys that
2401    /// were recovered from the corrupt document (any other field fell back to
2402    /// the backup/default baseline instead).
2403    Salvaged { fields: Vec<String> },
2404    /// Recovered wholesale from a `config.json.bak[.N]` generation
2405    /// (`generation` 0 == `.bak`, 1 == `.bak.1`, …).
2406    Backup { generation: usize },
2407    /// No usable salvage or backup; fell back to built-in defaults.
2408    Defaults,
2409}
2410
2411/// Describes a pending config-corruption recovery (#153, following on from
2412/// #37/#135's quarantine + salvage/backup chain): `config.json` failed to
2413/// parse at load time, the corrupt original was quarantined (copied aside,
2414/// not deleted) to `quarantine_path`, and the owning [`Config`] holds the
2415/// recovered in-memory state instead.
2416///
2417/// [`Config::save_to_dir`] refuses to overwrite `config.json` while
2418/// `confirmed` is `false`, so a user who would rather hand-fix the original
2419/// isn't surprised by an automatic overwrite on the next save. Call
2420/// [`Config::confirm_recovery`] (or [`Config::confirm_recovery_and_save_to_dir`])
2421/// to allow the next save through.
2422#[derive(Debug, Clone, PartialEq, Serialize)]
2423pub struct ConfigRecoveryStatus {
2424    /// Where the recovered values came from.
2425    pub source: ConfigRecoverySource,
2426    /// Absolute path of the preserved copy of the corrupt original
2427    /// (`config.json.corrupted.<nanos>`), or `None` if even the quarantine
2428    /// copy failed (the corrupt original still remains in place at
2429    /// `config.json` itself either way — quarantining copies, it doesn't
2430    /// move — so the guard below still applies).
2431    pub quarantine_path: Option<PathBuf>,
2432    /// Set `true` once the user has explicitly confirmed the recovery; only
2433    /// then may `save_to_dir` persist over the original `config.json`.
2434    pub confirmed: bool,
2435}
2436
2437/// Container for provider-specific configurations
2438///
2439/// Each field is optional, allowing users to configure only the providers they need.
2440#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2441pub struct ProviderConfigs {
2442    /// OpenAI provider configuration
2443    #[serde(skip_serializing_if = "Option::is_none")]
2444    pub openai: Option<OpenAIConfig>,
2445    /// Anthropic provider configuration
2446    #[serde(skip_serializing_if = "Option::is_none")]
2447    pub anthropic: Option<AnthropicConfig>,
2448    /// Google Gemini provider configuration
2449    #[serde(skip_serializing_if = "Option::is_none")]
2450    pub gemini: Option<GeminiConfig>,
2451    /// GitHub Copilot provider configuration
2452    #[serde(skip_serializing_if = "Option::is_none")]
2453    pub copilot: Option<CopilotConfig>,
2454    /// Bodhi proxy provider configuration
2455    #[serde(skip_serializing_if = "Option::is_none")]
2456    pub bodhi: Option<BodhiConfig>,
2457
2458    /// Preserve unknown provider keys (forward compatibility).
2459    #[serde(default, flatten)]
2460    pub extra: BTreeMap<String, Value>,
2461}
2462
2463impl ProviderConfigs {
2464    /// Remove only the known legacy selector and built-in aliases while
2465    /// preserving unknown forward-compatible provider entries in `extra`.
2466    pub fn clear_legacy_builtin_aliases(&mut self) {
2467        self.openai = None;
2468        self.anthropic = None;
2469        self.gemini = None;
2470        self.copilot = None;
2471        self.bodhi = None;
2472        self.extra.remove("provider");
2473    }
2474}
2475
2476/// Feature flags for incremental rollout of new subsystems.
2477#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2478pub struct FeatureFlags {
2479    /// File-authoritative Tickets for a newly bootstrapped Supervisor only.
2480    #[serde(default)]
2481    pub ticket_mutation: bool,
2482    /// Actual fresh one-shot dispatch; independent of Ticket record writes.
2483    #[serde(default)]
2484    pub ticket_dispatch: bool,
2485    /// Enable the ProviderModelRef system (multi-provider + unified model selection).
2486    #[serde(default)]
2487    pub provider_model_ref: bool,
2488    /// Enable MiniLoop-based complexity evaluation and dynamic per-round model switching.
2489    #[serde(default)]
2490    pub dynamic_model_routing: bool,
2491    /// Check a final answer once against this request's recorded tool evidence.
2492    #[serde(default)]
2493    pub final_evidence_check: bool,
2494}
2495
2496/// Default model assignments for specific capabilities.
2497///
2498/// Used when `features.provider_model_ref` is enabled.
2499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2500pub struct DefaultsConfig {
2501    pub chat: bamboo_domain::ProviderModelRef,
2502    #[serde(default, skip_serializing_if = "Option::is_none")]
2503    pub fast: Option<bamboo_domain::ProviderModelRef>,
2504    #[serde(default, skip_serializing_if = "Option::is_none")]
2505    pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2506    #[serde(default, skip_serializing_if = "Option::is_none")]
2507    pub vision: Option<bamboo_domain::ProviderModelRef>,
2508    #[serde(default, skip_serializing_if = "Option::is_none")]
2509    pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2510    /// Model for planning/coordination tasks (task decomposition, architecture).
2511    /// Falls back to `fast`, then `chat`, when unset.
2512    #[serde(default, skip_serializing_if = "Option::is_none")]
2513    pub planning: Option<bamboo_domain::ProviderModelRef>,
2514    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
2515    /// Falls back to `fast`, then `chat`, when unset.
2516    #[serde(default, skip_serializing_if = "Option::is_none")]
2517    pub search: Option<bamboo_domain::ProviderModelRef>,
2518    /// Model for code review tasks.
2519    /// Falls back to `fast`, then `chat`, when unset.
2520    #[serde(default, skip_serializing_if = "Option::is_none")]
2521    pub code_review: Option<bamboo_domain::ProviderModelRef>,
2522    /// Default model for child SubAgent runs.
2523    /// Falls back to `fast`, then `chat` when unset.
2524    #[serde(
2525        default,
2526        skip_serializing_if = "Option::is_none",
2527        alias = "sub_session"
2528    )]
2529    pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2530    /// Per-subagent-type model overrides.
2531    /// Key = subagent_type (e.g. "researcher", "coder"), Value = ProviderModelRef.
2532    /// Falls back to `sub_agent`, then `fast`, then `chat` when no match is found.
2533    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2534    pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2535}
2536
2537/// Request hook configuration.
2538#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2539pub struct HooksConfig {
2540    /// Image fallback behavior for OpenAI-compatible requests (chat/responses).
2541    #[serde(default)]
2542    pub image_fallback: ImageFallbackHookConfig,
2543}
2544
2545/// Default deadline for one lifecycle command hook.
2546pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2547/// Smallest accepted lifecycle hook deadline at the config API boundary.
2548pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2549/// Largest accepted lifecycle hook deadline (10 minutes). Lifecycle hooks run
2550/// inline with agent progress, so they share the same upper bound as Bamboo's
2551/// interactive shell tool instead of allowing an accidental hours-long stall.
2552pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2553
2554/// Stable user-facing event keys accepted by `lifecycle_hooks`.
2555pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 8] = [
2556    "SessionStart",
2557    "UserPromptSubmit",
2558    "PreToolUse",
2559    "PostToolUse",
2560    "Stop",
2561    "SessionEnd",
2562    "PreCompact",
2563    "Notification",
2564];
2565
2566fn default_lifecycle_hook_timeout_ms() -> u64 {
2567    DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2568}
2569
2570fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2571    *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2572}
2573
2574fn lifecycle_hook_enabled_default() -> bool {
2575    true
2576}
2577
2578fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2579    *value
2580}
2581
2582/// Config-driven agent lifecycle hooks.
2583///
2584/// Event names deliberately preserve the user-facing PascalCase protocol.
2585/// Server-owned events use the same stable schema as engine-owned events.
2586#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2587pub struct LifecycleHooksConfig {
2588    #[serde(default)]
2589    pub enabled: bool,
2590    #[serde(
2591        default,
2592        rename = "SessionStart",
2593        skip_serializing_if = "Vec::is_empty"
2594    )]
2595    pub session_start: Vec<LifecycleHookGroup>,
2596    #[serde(
2597        default,
2598        rename = "UserPromptSubmit",
2599        skip_serializing_if = "Vec::is_empty"
2600    )]
2601    pub user_prompt_submit: Vec<LifecycleHookGroup>,
2602    #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2603    pub pre_tool_use: Vec<LifecycleHookGroup>,
2604    #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2605    pub post_tool_use: Vec<LifecycleHookGroup>,
2606    #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2607    pub stop: Vec<LifecycleHookGroup>,
2608    #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2609    pub session_end: Vec<LifecycleHookGroup>,
2610    #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2611    pub pre_compact: Vec<LifecycleHookGroup>,
2612    #[serde(
2613        default,
2614        rename = "Notification",
2615        skip_serializing_if = "Vec::is_empty"
2616    )]
2617    pub notification: Vec<LifecycleHookGroup>,
2618}
2619
2620impl LifecycleHooksConfig {
2621    pub fn is_empty(&self) -> bool {
2622        !self.enabled
2623            && self.session_start.is_empty()
2624            && self.user_prompt_submit.is_empty()
2625            && self.pre_tool_use.is_empty()
2626            && self.post_tool_use.is_empty()
2627            && self.stop.is_empty()
2628            && self.session_end.is_empty()
2629            && self.pre_compact.is_empty()
2630            && self.notification.is_empty()
2631    }
2632}
2633
2634/// A matcher and its ordered handler list for one lifecycle event.
2635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2636pub struct LifecycleHookGroup {
2637    /// A disabled group remains persisted and editable but is not registered
2638    /// for execution. Missing values default to true for old config files.
2639    #[serde(
2640        default = "lifecycle_hook_enabled_default",
2641        skip_serializing_if = "lifecycle_hook_enabled_is_default"
2642    )]
2643    pub enabled: bool,
2644    #[serde(default, skip_serializing_if = "Option::is_none")]
2645    pub matcher: Option<String>,
2646    #[serde(default)]
2647    pub hooks: Vec<LifecycleHookHandler>,
2648}
2649
2650impl Default for LifecycleHookGroup {
2651    fn default() -> Self {
2652        Self {
2653            enabled: true,
2654            matcher: None,
2655            hooks: Vec::new(),
2656        }
2657    }
2658}
2659
2660/// One configured lifecycle hook handler.
2661///
2662/// The internally tagged representation preserves the existing command JSON
2663/// while allowing handler-specific validation for external scripts.
2664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2665#[serde(tag = "type", rename_all = "lowercase")]
2666pub enum LifecycleHookHandler {
2667    Command {
2668        command: String,
2669        #[serde(
2670            default = "default_lifecycle_hook_timeout_ms",
2671            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2672        )]
2673        timeout_ms: u64,
2674    },
2675    Script {
2676        path: String,
2677        #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2678        runner: LifecycleScriptRunner,
2679        #[serde(
2680            default = "default_lifecycle_hook_timeout_ms",
2681            skip_serializing_if = "lifecycle_hook_timeout_is_default"
2682        )]
2683        timeout_ms: u64,
2684    },
2685}
2686
2687/// Runtime used to execute a lifecycle script.
2688///
2689/// `auto` infers the language from the file extension and tries the system
2690/// runtimes in a deterministic order. Explicit runners are useful when both
2691/// Node.js and Bun are installed or when a deployment standardizes one binary.
2692#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2693#[serde(rename_all = "lowercase")]
2694pub enum LifecycleScriptRunner {
2695    #[default]
2696    Auto,
2697    Node,
2698    Bun,
2699    Python,
2700    Bash,
2701    PowerShell,
2702    Cmd,
2703}
2704
2705impl LifecycleScriptRunner {
2706    pub fn is_auto(&self) -> bool {
2707        matches!(self, Self::Auto)
2708    }
2709
2710    pub fn as_str(self) -> &'static str {
2711        match self {
2712            Self::Auto => "auto",
2713            Self::Node => "node",
2714            Self::Bun => "bun",
2715            Self::Python => "python",
2716            Self::Bash => "bash",
2717            Self::PowerShell => "powershell",
2718            Self::Cmd => "cmd",
2719        }
2720    }
2721
2722    /// Whether this runner can execute the supplied supported script path.
2723    pub fn supports_path(self, path: &str) -> bool {
2724        let extension = lifecycle_script_extension(path);
2725        match self {
2726            Self::Auto => extension.is_some(),
2727            Self::Node | Self::Bun => {
2728                matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2729            }
2730            Self::Python => matches!(extension.as_deref(), Some("py")),
2731            Self::Bash => matches!(extension.as_deref(), Some("sh")),
2732            Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2733            Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2734        }
2735    }
2736}
2737
2738/// Return the normalized extension when the path names a supported lifecycle
2739/// script.
2740pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2741    let extension = std::path::Path::new(path)
2742        .extension()?
2743        .to_str()?
2744        .to_ascii_lowercase();
2745    matches!(
2746        extension.as_str(),
2747        "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2748    )
2749    .then_some(extension)
2750}
2751
2752impl LifecycleHookHandler {
2753    pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2754        Self::Command {
2755            command: command.into(),
2756            timeout_ms,
2757        }
2758    }
2759
2760    pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2761        Self::Script {
2762            path: path.into(),
2763            runner,
2764            timeout_ms,
2765        }
2766    }
2767
2768    pub fn timeout_ms(&self) -> u64 {
2769        match self {
2770            Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2771        }
2772    }
2773}
2774
2775/// Request override configuration for provider-specific HTTP behavior.
2776///
2777/// Overrides are merged in this order (later wins):
2778/// 1. `common`
2779/// 2. `endpoints[endpoint]`
2780/// 3. matching `rules` (sorted by specificity)
2781#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2782pub struct RequestOverridesConfig {
2783    /// Overrides applied to all endpoints.
2784    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2785    pub common: RequestScopeOverride,
2786    /// Endpoint-specific overrides (`chat_completions`, `responses`, `messages`, etc.).
2787    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2788    pub endpoints: BTreeMap<String, RequestScopeOverride>,
2789    /// Model-conditional overrides.
2790    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2791    pub rules: Vec<ModelRequestRule>,
2792}
2793
2794/// A conditional override rule matching a model pattern.
2795#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2796pub struct ModelRequestRule {
2797    /// Model pattern (exact: `gpt-4o`, prefix wildcard: `gpt-5*`).
2798    pub model_pattern: String,
2799    /// Optional endpoint constraint.
2800    #[serde(default, skip_serializing_if = "Option::is_none")]
2801    pub endpoint: Option<String>,
2802    /// Overrides applied when this rule matches.
2803    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2804    pub scope: RequestScopeOverride,
2805}
2806
2807/// Request overrides applied in a specific scope.
2808#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2809pub struct RequestScopeOverride {
2810    /// Extra or overridden HTTP headers.
2811    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2812    pub headers: BTreeMap<String, TemplateExpr>,
2813    /// JSON body patch operations.
2814    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2815    pub body_patch: Vec<BodyPatch>,
2816}
2817
2818impl RequestScopeOverride {
2819    pub fn is_empty(&self) -> bool {
2820        self.headers.is_empty() && self.body_patch.is_empty()
2821    }
2822}
2823
2824/// Body patch operation.
2825#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2826pub struct BodyPatch {
2827    /// Target path (`foo.bar.0` or `/foo/bar/0`).
2828    pub path: String,
2829    /// Operation type.
2830    #[serde(default)]
2831    pub op: BodyPatchOp,
2832    /// Value for `set` operation.
2833    #[serde(default, skip_serializing_if = "Option::is_none")]
2834    pub value: Option<PatchValue>,
2835}
2836
2837/// Supported body patch operations.
2838#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2839#[serde(rename_all = "snake_case")]
2840pub enum BodyPatchOp {
2841    #[default]
2842    Set,
2843    Remove,
2844}
2845
2846/// Body patch value: either a template expression or a raw JSON value.
2847#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2848#[serde(untagged)]
2849pub enum PatchValue {
2850    Template(TemplateExpr),
2851    Json(Value),
2852}
2853
2854/// String template expression used by headers/body patch values.
2855#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2856#[serde(untagged)]
2857pub enum TemplateExpr {
2858    /// Shorthand literal value.
2859    Literal(String),
2860    /// Structured template expression.
2861    Structured(TemplateExprSpec),
2862}
2863
2864/// Structured template expression.
2865#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2866#[serde(tag = "type", rename_all = "snake_case")]
2867pub enum TemplateExprSpec {
2868    /// Literal string value.
2869    Literal { value: String },
2870    /// Reference a value from Bamboo env vars.
2871    EnvRef {
2872        name: String,
2873        #[serde(default, skip_serializing_if = "Option::is_none")]
2874        fallback: Option<String>,
2875    },
2876    /// Generate a runtime value.
2877    Generated { generator: GeneratedValue },
2878    /// Format string with placeholders (`{env:NAME}`, `{uuid}`, `{unix_ms}`).
2879    Format { template: String },
2880}
2881
2882/// Supported generated value kinds.
2883#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2884#[serde(rename_all = "snake_case")]
2885pub enum GeneratedValue {
2886    Uuid,
2887    UnixMs,
2888}
2889
2890/// Global tool toggle configuration.
2891#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2892pub struct ToolsConfig {
2893    /// Tool names that are disabled globally.
2894    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2895    pub disabled: Vec<String>,
2896
2897    /// Preserve tool configuration owned by newer Bamboo versions or plugins.
2898    #[serde(default, flatten)]
2899    pub extra: BTreeMap<String, Value>,
2900}
2901
2902impl ToolsConfig {
2903    fn is_empty(&self) -> bool {
2904        self.disabled.is_empty() && self.extra.is_empty()
2905    }
2906}
2907
2908/// Global skill toggle configuration.
2909#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2910pub struct SkillsConfig {
2911    /// Skill IDs that are disabled globally.
2912    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2913    pub disabled: Vec<String>,
2914
2915    /// Preserve skill configuration owned by newer Bamboo versions or plugins.
2916    #[serde(default, flatten)]
2917    pub extra: BTreeMap<String, Value>,
2918}
2919
2920impl SkillsConfig {
2921    fn is_empty(&self) -> bool {
2922        self.disabled.is_empty() && self.extra.is_empty()
2923    }
2924}
2925
2926/// When a request contains image parts but the effective provider path is text-only,
2927/// we can either:
2928/// - error fast (preferred for strict setups), or
2929/// - degrade gracefully by replacing images with a placeholder text.
2930#[derive(Debug, Clone, Serialize, Deserialize)]
2931pub struct ImageFallbackHookConfig {
2932    #[serde(default = "default_true_hooks")]
2933    pub enabled: bool,
2934
2935    /// "placeholder" (default) or "error"
2936    #[serde(default = "default_image_fallback_mode")]
2937    pub mode: String,
2938}
2939
2940impl Default for ImageFallbackHookConfig {
2941    fn default() -> Self {
2942        Self {
2943            enabled: default_true_hooks(),
2944            mode: default_image_fallback_mode(),
2945        }
2946    }
2947}
2948
2949fn default_image_fallback_mode() -> String {
2950    "placeholder".to_string()
2951}
2952
2953fn default_true_hooks() -> bool {
2954    // Default to disabled so image inputs are preserved unless the user explicitly
2955    // opts into fallback rewriting (placeholder/error/ocr).
2956    false
2957}
2958
2959/// OpenAI provider configuration
2960///
2961/// # Example
2962///
2963/// ```json
2964/// "openai": {
2965///   "api_key": "sk-...",
2966///   "base_url": "https://api.openai.com/v1",
2967///   "model": "gpt-4"
2968/// }
2969/// ```
2970pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
2971
2972#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2973pub struct OpenAIConfig {
2974    /// OpenAI API key (plaintext, in-memory only).
2975    ///
2976    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
2977    #[serde(default, skip_serializing)]
2978    pub api_key: String,
2979    /// Encrypted OpenAI API key (nonce:ciphertext).
2980    #[serde(default, skip_serializing_if = "Option::is_none")]
2981    pub api_key_encrypted: Option<String>,
2982    /// Stable reference to the isolated credential store.
2983    #[serde(default, skip_serializing_if = "Option::is_none")]
2984    pub credential_ref: Option<crate::CredentialRef>,
2985    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
2986    /// Such keys are runtime-only and MUST NOT be re-encrypted into
2987    /// `api_key_encrypted` on save (that would bake the secret into
2988    /// config.json). Not (de)serialized. (#253)
2989    #[serde(skip)]
2990    pub api_key_from_env: bool,
2991    /// Custom API base URL (for Azure or self-hosted deployments)
2992    #[serde(skip_serializing_if = "Option::is_none")]
2993    pub base_url: Option<String>,
2994    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
2995    #[serde(skip_serializing_if = "Option::is_none")]
2996    pub model: Option<String>,
2997    /// Fast/cheap model for lightweight tasks (title generation and summarization).
2998    /// Falls back to `model` when not set.
2999    #[serde(default, skip_serializing_if = "Option::is_none")]
3000    pub fast_model: Option<String>,
3001    /// Vision-capable model for image understanding tasks.
3002    /// Falls back to `model` when not set.
3003    #[serde(default, skip_serializing_if = "Option::is_none")]
3004    pub vision_model: Option<String>,
3005    /// Default reasoning effort for OpenAI requests.
3006    #[serde(skip_serializing_if = "Option::is_none")]
3007    pub reasoning_effort: Option<ReasoningEffort>,
3008
3009    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3010    ///
3011    /// Example:
3012    /// ```json
3013    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3014    /// ```
3015    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3016    pub responses_only_models: Vec<String>,
3017    /// Optional request overrides (headers/body patches/model rules).
3018    #[serde(default, skip_serializing_if = "Option::is_none")]
3019    pub request_overrides: Option<RequestOverridesConfig>,
3020
3021    /// Preserve unknown keys under `providers.openai`.
3022    #[serde(default, flatten)]
3023    pub extra: BTreeMap<String, Value>,
3024}
3025
3026impl OpenAIConfig {
3027    /// Whether Bamboo may lower its provider-neutral cache plan into GPT-5.6+
3028    /// `prompt_cache_options` and `prompt_cache_breakpoint` fields.
3029    ///
3030    /// This defaults to enabled. OpenAI-compatible upstreams that have not yet
3031    /// implemented the explicit-cache request fields can opt out without
3032    /// disabling `prompt_cache_key` or the upstream's implicit prompt cache.
3033    pub fn explicit_prompt_cache_enabled(&self) -> bool {
3034        self.extra
3035            .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3036            .and_then(Value::as_bool)
3037            .unwrap_or(true)
3038    }
3039}
3040
3041/// Anthropic provider configuration
3042///
3043/// # Example
3044///
3045/// ```json
3046/// "anthropic": {
3047///   "api_key": "sk-ant-...",
3048///   "model": "claude-3-5-sonnet-20241022",
3049///   "max_tokens": 4096
3050/// }
3051/// ```
3052#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3053pub struct AnthropicConfig {
3054    /// Anthropic API key (plaintext, in-memory only).
3055    ///
3056    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3057    #[serde(default, skip_serializing)]
3058    pub api_key: String,
3059    /// Encrypted Anthropic API key (nonce:ciphertext).
3060    #[serde(default, skip_serializing_if = "Option::is_none")]
3061    pub api_key_encrypted: Option<String>,
3062    /// Stable reference to the isolated credential store.
3063    #[serde(default, skip_serializing_if = "Option::is_none")]
3064    pub credential_ref: Option<crate::CredentialRef>,
3065    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3066    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3067    /// `api_key_encrypted` on save (that would bake the secret into
3068    /// config.json). Not (de)serialized. (#253)
3069    #[serde(skip)]
3070    pub api_key_from_env: bool,
3071    /// Custom API base URL
3072    #[serde(skip_serializing_if = "Option::is_none")]
3073    pub base_url: Option<String>,
3074    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
3075    #[serde(skip_serializing_if = "Option::is_none")]
3076    pub model: Option<String>,
3077    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3078    /// Falls back to `model` when not set.
3079    #[serde(default, skip_serializing_if = "Option::is_none")]
3080    pub fast_model: Option<String>,
3081    /// Vision-capable model for image understanding tasks.
3082    /// Falls back to `model` when not set.
3083    #[serde(default, skip_serializing_if = "Option::is_none")]
3084    pub vision_model: Option<String>,
3085    /// Maximum tokens in model response
3086    #[serde(skip_serializing_if = "Option::is_none")]
3087    pub max_tokens: Option<u32>,
3088    /// Default reasoning effort for Anthropic requests.
3089    #[serde(skip_serializing_if = "Option::is_none")]
3090    pub reasoning_effort: Option<ReasoningEffort>,
3091    /// Optional request overrides (headers/body patches/model rules).
3092    #[serde(default, skip_serializing_if = "Option::is_none")]
3093    pub request_overrides: Option<RequestOverridesConfig>,
3094
3095    /// Unconditionally replay a prior turn's `reasoning` as a `thinking`
3096    /// content block, regardless of whether bamboo captured a valid signature
3097    /// for it (issue #520).
3098    ///
3099    /// Defaults to `false`/absent, which is REQUIRED for real Anthropic: it
3100    /// requires `thinking` input blocks to carry a signature it minted itself,
3101    /// and bamboo never captures one, so an unconditionally-replayed block is
3102    /// always rejected with a 400 (either because it's foreign — minted by a
3103    /// different provider after a mid-session model switch — or because it's
3104    /// an unsigned copy of Claude's own prior turn).
3105    ///
3106    /// Set this to `true` only when pointing `base_url` at an
3107    /// Anthropic-COMPATIBLE upstream (e.g. GLM's `/anthropic` endpoint) that
3108    /// has the opposite contract: it requires the `thinking` block to be
3109    /// present whenever thinking is enabled, but never validates its
3110    /// signature.
3111    #[serde(default, skip_serializing_if = "Option::is_none")]
3112    pub thinking_replay_always: Option<bool>,
3113
3114    /// Preserve unknown keys under `providers.anthropic`.
3115    #[serde(default, flatten)]
3116    pub extra: BTreeMap<String, Value>,
3117}
3118
3119/// Google Gemini provider configuration
3120///
3121/// # Example
3122///
3123/// ```json
3124/// "gemini": {
3125///   "api_key": "AIza...",
3126///   "model": "gemini-2.0-flash-exp"
3127/// }
3128/// ```
3129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3130pub struct GeminiConfig {
3131    /// Google AI API key (plaintext, in-memory only).
3132    ///
3133    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
3134    #[serde(default, skip_serializing)]
3135    pub api_key: String,
3136    /// Encrypted Google AI API key (nonce:ciphertext).
3137    #[serde(default, skip_serializing_if = "Option::is_none")]
3138    pub api_key_encrypted: Option<String>,
3139    /// Stable reference to the isolated credential store.
3140    #[serde(default, skip_serializing_if = "Option::is_none")]
3141    pub credential_ref: Option<crate::CredentialRef>,
3142    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
3143    /// Such keys are runtime-only and MUST NOT be re-encrypted into
3144    /// `api_key_encrypted` on save (that would bake the secret into
3145    /// config.json). Not (de)serialized. (#253)
3146    #[serde(skip)]
3147    pub api_key_from_env: bool,
3148    /// Custom API base URL
3149    #[serde(skip_serializing_if = "Option::is_none")]
3150    pub base_url: Option<String>,
3151    /// Default model to use (e.g., "gemini-2.0-flash-exp")
3152    #[serde(skip_serializing_if = "Option::is_none")]
3153    pub model: Option<String>,
3154    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3155    /// Falls back to `model` when not set.
3156    #[serde(default, skip_serializing_if = "Option::is_none")]
3157    pub fast_model: Option<String>,
3158    /// Vision-capable model for image understanding tasks.
3159    /// Falls back to `model` when not set.
3160    #[serde(default, skip_serializing_if = "Option::is_none")]
3161    pub vision_model: Option<String>,
3162    /// Default reasoning effort for Gemini requests.
3163    #[serde(skip_serializing_if = "Option::is_none")]
3164    pub reasoning_effort: Option<ReasoningEffort>,
3165    /// Optional request overrides (headers/body patches/model rules).
3166    #[serde(default, skip_serializing_if = "Option::is_none")]
3167    pub request_overrides: Option<RequestOverridesConfig>,
3168
3169    /// Preserve unknown keys under `providers.gemini`.
3170    #[serde(default, flatten)]
3171    pub extra: BTreeMap<String, Value>,
3172}
3173
3174/// GitHub Copilot provider configuration
3175///
3176/// # Example
3177///
3178/// ```json
3179/// "copilot": {
3180///   "enabled": true,
3181///   "headless_auth": false,
3182///   "model": "gpt-4o"
3183/// }
3184/// ```
3185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3186pub struct CopilotConfig {
3187    /// Whether Copilot provider is enabled
3188    #[serde(default)]
3189    pub enabled: bool,
3190    /// Print login URL to console instead of opening browser
3191    #[serde(default)]
3192    pub headless_auth: bool,
3193    /// Default model to use for Copilot (used when clients request the "default" model)
3194    #[serde(skip_serializing_if = "Option::is_none")]
3195    pub model: Option<String>,
3196    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
3197    /// Falls back to `model` when not set.
3198    #[serde(default, skip_serializing_if = "Option::is_none")]
3199    pub fast_model: Option<String>,
3200    /// Vision-capable model for image understanding tasks.
3201    /// Falls back to `model` when not set.
3202    #[serde(default, skip_serializing_if = "Option::is_none")]
3203    pub vision_model: Option<String>,
3204    /// Default reasoning effort for Copilot requests.
3205    #[serde(skip_serializing_if = "Option::is_none")]
3206    pub reasoning_effort: Option<ReasoningEffort>,
3207
3208    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
3209    ///
3210    /// This is useful for newer Copilot models that only support Responses-style requests.
3211    ///
3212    /// Example:
3213    /// ```json
3214    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
3215    /// ```
3216    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3217    pub responses_only_models: Vec<String>,
3218    /// Optional request overrides (headers/body patches/model rules).
3219    #[serde(default, skip_serializing_if = "Option::is_none")]
3220    pub request_overrides: Option<RequestOverridesConfig>,
3221
3222    /// Preserve unknown keys under `providers.copilot`.
3223    #[serde(default, flatten)]
3224    pub extra: BTreeMap<String, Value>,
3225}
3226
3227/// Bodhi proxy provider configuration.
3228///
3229/// Routes LLM requests through a bodhi-server instance so that raw provider
3230/// API keys never reach the client.
3231#[derive(Debug, Clone, Serialize, Deserialize)]
3232pub struct BodhiConfig {
3233    /// Bodhi server API key (e.g. "bhi_sk_xxx").  In-memory only.
3234    #[serde(default, skip_serializing)]
3235    pub api_key: String,
3236    /// Encrypted form of the API key stored on disk.
3237    #[serde(default, skip_serializing_if = "Option::is_none")]
3238    pub api_key_encrypted: Option<String>,
3239    /// Stable reference to the isolated credential store.
3240    #[serde(default, skip_serializing_if = "Option::is_none")]
3241    pub credential_ref: Option<crate::CredentialRef>,
3242    /// Bodhi server base URL.
3243    #[serde(skip_serializing_if = "Option::is_none")]
3244    pub base_url: Option<String>,
3245    /// Which upstream provider to route through bodhi ("openai", "anthropic", "gemini").
3246    #[serde(skip_serializing_if = "Option::is_none")]
3247    pub target_provider: Option<String>,
3248    /// Default reasoning effort.
3249    #[serde(skip_serializing_if = "Option::is_none")]
3250    pub reasoning_effort: Option<ReasoningEffort>,
3251
3252    /// Preserve unknown keys.
3253    #[serde(default, flatten)]
3254    pub extra: BTreeMap<String, Value>,
3255}
3256
3257/// Returns the default provider name ("anthropic")
3258fn default_provider() -> String {
3259    "anthropic".to_string()
3260}
3261
3262// ─── Provider Instance Configuration ──────────────────────────────────
3263
3264/// Configuration for a single provider instance.
3265///
3266/// Multiple instances of the same provider type (e.g. two OpenAI accounts)
3267/// can coexist. Each instance is identified by a stable `instance_id` that
3268/// is used as the routing key in [`ProviderModelRef::provider`] and the
3269/// provider registry.
3270///
3271/// # Example (config.json)
3272///
3273/// ```json
3274/// {
3275///   "provider_instances": {
3276///     "openai-work": {
3277///       "provider_type": "openai",
3278///       "label": "OpenAI (Work)",
3279///       "api_key": "sk-...",
3280///       "model": "gpt-4o"
3281///     },
3282///     "openai-personal": {
3283///       "provider_type": "openai",
3284///       "label": "OpenAI (Personal)",
3285///       "api_key": "sk-...",
3286///       "base_url": "https://api.openai.com/v1",
3287///       "model": "gpt-4o-mini"
3288///     }
3289///   },
3290///   "default_provider_instance": "openai-work"
3291/// }
3292/// ```
3293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3294pub struct ProviderInstanceConfig {
3295    /// Which provider backend this instance targets.
3296    ///
3297    /// Must be one of [`AVAILABLE_PROVIDERS`]: `"openai"`, `"anthropic"`,
3298    /// `"gemini"`, `"copilot"`, `"bodhi"`.
3299    pub provider_type: String,
3300
3301    /// Human-readable label shown in the UI / catalog.
3302    #[serde(default, skip_serializing_if = "Option::is_none")]
3303    pub label: Option<String>,
3304
3305    /// API key (plaintext in memory, encrypted at rest via `api_key_encrypted`).
3306    #[serde(default, skip_serializing)]
3307    pub api_key: String,
3308
3309    /// Encrypted API key (nonce:ciphertext). Written to disk; decrypted into
3310    /// `api_key` on load.
3311    #[serde(default, skip_serializing_if = "Option::is_none")]
3312    pub api_key_encrypted: Option<String>,
3313
3314    /// Stable reference to the isolated credential store.
3315    #[serde(default, skip_serializing_if = "Option::is_none")]
3316    pub credential_ref: Option<crate::CredentialRef>,
3317
3318    /// Custom base URL override.
3319    #[serde(default, skip_serializing_if = "Option::is_none")]
3320    pub base_url: Option<String>,
3321
3322    /// Default chat model for this instance.
3323    #[serde(default, skip_serializing_if = "Option::is_none")]
3324    pub model: Option<String>,
3325
3326    /// Fast/cheap model for lightweight tasks.
3327    #[serde(default, skip_serializing_if = "Option::is_none")]
3328    pub fast_model: Option<String>,
3329
3330    /// Vision-capable model.
3331    #[serde(default, skip_serializing_if = "Option::is_none")]
3332    pub vision_model: Option<String>,
3333
3334    /// Default reasoning effort.
3335    #[serde(default, skip_serializing_if = "Option::is_none")]
3336    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3337
3338    /// Models that must use the Responses API upstream (OpenAI only).
3339    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3340    pub responses_only_models: Vec<String>,
3341
3342    /// Optional request overrides (headers/body patches/model rules).
3343    #[serde(default, skip_serializing_if = "Option::is_none")]
3344    pub request_overrides: Option<RequestOverridesConfig>,
3345
3346    /// Whether this instance is enabled. Disabled instances are skipped
3347    /// during registry construction.
3348    #[serde(default = "default_true")]
3349    pub enabled: bool,
3350
3351    /// Provider-type-specific extra fields preserved through (de)serialization.
3352    #[serde(default, flatten)]
3353    pub extra: BTreeMap<String, Value>,
3354}
3355
3356fn default_true() -> bool {
3357    true
3358}
3359
3360/// Returns the default server port (9562)
3361fn default_port() -> u16 {
3362    9562
3363}
3364
3365/// Returns the default bind address (127.0.0.1)
3366fn default_bind() -> String {
3367    "127.0.0.1".to_string()
3368}
3369
3370/// Returns the default worker count (10)
3371fn default_workers() -> usize {
3372    10
3373}
3374
3375/// Returns the default data directory (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3376fn default_data_dir() -> PathBuf {
3377    super::paths::bamboo_dir()
3378}
3379
3380/// HTTP server configuration
3381#[derive(Debug, Clone, Serialize, Deserialize)]
3382pub struct ServerConfig {
3383    /// Port to listen on
3384    #[serde(default = "default_port")]
3385    pub port: u16,
3386
3387    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
3388    #[serde(default = "default_bind")]
3389    pub bind: String,
3390
3391    /// Static files directory (for Docker mode)
3392    pub static_dir: Option<PathBuf>,
3393
3394    /// Worker count for Actix-web
3395    #[serde(default = "default_workers")]
3396    pub workers: usize,
3397
3398    /// v2 (API v2 transport, #181): optional TLS termination config. When both
3399    /// `cert_file` and `key_file` are given, bamboo terminates TLS itself
3400    /// (rustls, no reverse proxy) and serves `https://` — intended for the
3401    /// public `0.0.0.0` face. When absent, the server keeps the plain `.bind()`
3402    /// / `.listen()` path unchanged (desktop loopback stays plaintext). Missing
3403    /// or unparseable cert/key files are fail-fast at startup, never a silent
3404    /// downgrade to plaintext.
3405    #[serde(default, skip_serializing_if = "Option::is_none")]
3406    pub tls: Option<TlsConfig>,
3407
3408    /// Preserve unknown keys under `server`.
3409    #[serde(default, flatten)]
3410    pub extra: BTreeMap<String, Value>,
3411}
3412
3413impl Default for ServerConfig {
3414    fn default() -> Self {
3415        Self {
3416            port: default_port(),
3417            bind: default_bind(),
3418            static_dir: None,
3419            workers: default_workers(),
3420            tls: None,
3421            extra: BTreeMap::new(),
3422        }
3423    }
3424}
3425
3426/// Manual TLS certificate configuration (current stage; ACME deferred).
3427///
3428/// Both fields point at PEM files: `cert_file` is the full certificate chain
3429/// (leaf → intermediates → root), `key_file` is the matching private key
3430/// (PKCS#8 or RSA). See `docs/api-v2-transport.md` §3.
3431#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3432pub struct TlsConfig {
3433    /// PEM certificate chain (leaf → intermediates → root).
3434    pub cert_file: PathBuf,
3435    /// PEM private key (PKCS#8 or RSA).
3436    pub key_file: PathBuf,
3437}
3438
3439/// Proxy authentication credentials
3440#[derive(Debug, Clone, Serialize, Deserialize)]
3441pub struct ProxyAuth {
3442    /// Proxy username
3443    pub username: String,
3444    /// Proxy password
3445    pub password: String,
3446}
3447
3448/// Parse a boolean value from environment variable strings
3449///
3450/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
3451fn parse_bool_env(value: &str) -> bool {
3452    matches!(
3453        value.trim().to_ascii_lowercase().as_str(),
3454        "1" | "true" | "yes" | "y" | "on"
3455    )
3456}
3457
3458fn expand_user_path(value: &str) -> PathBuf {
3459    let trimmed = value.trim();
3460    if let Some(rest) = trimmed.strip_prefix("~/") {
3461        if let Some(home) = dirs::home_dir() {
3462            return home.join(rest);
3463        }
3464    }
3465    PathBuf::from(trimmed)
3466}
3467
3468impl Default for Config {
3469    fn default() -> Self {
3470        // In-memory defaults ONLY. `default()` must not touch the filesystem or
3471        // environment: it was delegating to `new()` → `from_data_dir(None)`,
3472        // which read config.json from disk, applied BAMBOO_* env overrides, and
3473        // published to the global env-var cache. That made every `..Default::
3474        // default()` struct-update and every test silently disk-dependent and
3475        // let non-server callers clobber the server's in-memory config cache.
3476        // `create_default()` is the pure in-memory constructor; disk loading is
3477        // the explicit job of `new()` / `from_data_dir()`. #38.
3478        Self::create_default()
3479    }
3480}
3481
3482/// Prompt-safe snapshot of configured env vars.
3483#[derive(Debug, Clone, PartialEq, Eq)]
3484pub struct PromptSafeEnvVarEntry {
3485    pub name: String,
3486    pub secret: bool,
3487    pub description: Option<String>,
3488}
3489
3490/// Global cache of user-managed env vars for injection into child processes.
3491///
3492/// Updated whenever the config is loaded or reloaded via [`Config::publish_env_vars`].
3493static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3494
3495static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3496
3497fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3498    ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3499}
3500
3501fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3502    PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3503}
3504
3505impl Config {
3506    fn from_parts(
3507        values: ConfigValues,
3508        memory: Option<MemoryConfig>,
3509        subagents: SubagentsConfig,
3510        providers: ProviderConfigs,
3511    ) -> Self {
3512        Self {
3513            values,
3514            memory: crate::MemoryConfigModule(memory),
3515            subagents: crate::SubagentsConfigModule(subagents),
3516            providers: crate::ProviderConfigsModule(providers),
3517            recovery_status: None,
3518        }
3519    }
3520
3521    /// Crate-internal seam used by the modular section facade.
3522    ///
3523    /// Keeping this conversion here lets the facade exhaustively destructure
3524    /// [`ConfigValues`] in both directions without exposing the compatibility
3525    /// facade's private storage to downstream crates.
3526    pub(crate) fn section_values(&self) -> ConfigValues {
3527        self.values.clone()
3528    }
3529
3530    pub(crate) fn from_section_parts(
3531        values: ConfigValues,
3532        memory: Option<MemoryConfig>,
3533        subagents: SubagentsConfig,
3534        providers: ProviderConfigs,
3535    ) -> Self {
3536        Self::from_parts(values, memory, subagents, providers)
3537    }
3538
3539    /// Compatibility accessor for independently persisted memory settings.
3540    pub fn memory(&self) -> &Option<MemoryConfig> {
3541        &self.memory.0
3542    }
3543
3544    pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3545        &mut self.memory.0
3546    }
3547
3548    /// Compatibility accessor for independently persisted sub-agent settings.
3549    pub fn subagents(&self) -> &SubagentsConfig {
3550        &self.subagents.0
3551    }
3552
3553    pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3554        &mut self.subagents.0
3555    }
3556
3557    /// Compatibility accessor for independently persisted legacy providers.
3558    pub fn providers(&self) -> &ProviderConfigs {
3559        &self.providers.0
3560    }
3561
3562    pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3563        &mut self.providers.0
3564    }
3565
3566    /// Canonicalize only the durable provider view when instance routing is
3567    /// authoritative. Unknown provider entries remain available for forward
3568    /// compatibility.
3569    pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3570        if self
3571            .default_provider_instance
3572            .as_ref()
3573            .is_some_and(|id| self.provider_instances.contains_key(id))
3574        {
3575            self.providers.0.clear_legacy_builtin_aliases();
3576        }
3577    }
3578
3579    /// Build the legacy full-config JSON view used by in-memory patching APIs.
3580    ///
3581    /// Public serde and patch/dot-path callers retain the historical full
3582    /// configuration shape. This value is never the persistence representation:
3583    /// [`Config::save_to_dir`] explicitly writes a root-only DTO plus sidecars.
3584    pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3585        let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3586        let object = value
3587            .as_object_mut()
3588            .expect("ConfigRoot always serializes as a JSON object");
3589        object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3590        object.insert(
3591            "subagents".to_string(),
3592            serde_json::to_value(self.subagents())?,
3593        );
3594        object.insert(
3595            "providers".to_string(),
3596            serde_json::to_value(self.providers())?,
3597        );
3598        Ok(value)
3599    }
3600
3601    /// Load configuration from file with environment variable overrides
3602    ///
3603    /// Configuration loading order:
3604    /// 1. Try loading from `config.json` (`{data_dir}/config.json`)
3605    /// 2. Use defaults
3606    /// 3. Apply environment variable overrides (highest priority)
3607    ///
3608    /// # Environment Variables
3609    ///
3610    /// - `BAMBOO_PORT`: Override server port
3611    /// - `BAMBOO_BIND`: Override bind address
3612    /// - `BAMBOO_DATA_DIR`: Override data directory
3613    /// - `BAMBOO_PROVIDER`: Override default provider
3614    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
3615    /// - `BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION`: Override project durable-memory index prompt injection
3616    /// - `BAMBOO_MEMORY_RELEVANT_RECALL`: Override relevant durable-memory recall prompt injection
3617    /// - `BAMBOO_MEMORY_RELEVANT_RECALL_RERANK`: Override model-based relevant recall reranking
3618    /// - `BAMBOO_MEMORY_PROJECT_FIRST_DREAM`: Override project-first Dream prompt behavior
3619    pub fn new() -> Self {
3620        Self::from_data_dir(None)
3621    }
3622
3623    fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3624        let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3625            Ok(facade) => facade.effective_config(),
3626            Err(error) => {
3627                tracing::warn!(
3628                    error = %error,
3629                    "completed modular configuration is unavailable; refusing legacy-root fallback"
3630                );
3631                Self::create_default()
3632            }
3633        };
3634
3635        if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3636            tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3637            config.proxy_auth = None;
3638        }
3639        if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3640            tracing::warn!(error = %error, "provider credential hydration unavailable");
3641        }
3642        if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3643            tracing::warn!(error = %error, "MCP credential hydration unavailable");
3644        }
3645        if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3646            tracing::warn!(error = %error, "env credential hydration unavailable");
3647            for entry in &mut config.env_vars {
3648                if entry.secret {
3649                    entry.value.clear();
3650                }
3651            }
3652        }
3653        if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3654            tracing::warn!(error = %error, "cluster credential hydration unavailable");
3655            config.clear_cluster_runtime_credentials();
3656        }
3657        if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3658            tracing::warn!(error = %error, "notification credential hydration unavailable");
3659            config.notifications.ntfy.token = None;
3660            config.notifications.bark.device_key = None;
3661        }
3662        if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3663            tracing::warn!(error = %error, "connect credential hydration unavailable");
3664            for platform in &mut config.connect.platforms {
3665                platform.token = None;
3666                platform.app_secret = None;
3667            }
3668        }
3669        if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3670            tracing::warn!(error = %error, "access-control credential hydration unavailable");
3671            config.clear_access_control_runtime_verifiers();
3672        }
3673        if let Some(broker) = config.subagents_mut().broker.as_mut() {
3674            if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3675                tracing::warn!(error = %error, "external broker credential hydration unavailable");
3676                broker.token.clear();
3677            }
3678        }
3679        config.normalize_tool_settings();
3680        config.normalize_skill_settings();
3681        config.normalize_plugin_trust_settings();
3682        config.extra.remove("data_dir");
3683        if apply_env {
3684            config.apply_env_overrides();
3685        }
3686        if publish {
3687            config.publish_env_vars();
3688        }
3689        config
3690    }
3691
3692    /// Load configuration from a specific data directory.
3693    ///
3694    /// Use [`Config::from_data_dir`] (publishes env vars to the global cache, for
3695    /// the context that OWNS the cache — the server bootstrap) or
3696    /// [`Config::from_data_dir_without_publish`] (for non-owning readers that must
3697    /// not clobber the live cache). #40.
3698    ///
3699    /// * `data_dir` - Optional data directory path. If None, uses default (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
3700    fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3701        // Determine data_dir early (needed to find config file)
3702        let data_dir = data_dir
3703            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3704            .unwrap_or_else(default_data_dir);
3705
3706        match crate::modular_authority_boundary_present(&data_dir) {
3707            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3708            Ok(false) => {}
3709            Err(error) => {
3710                tracing::warn!(
3711                    error = %error,
3712                    "modular configuration marker is unavailable; refusing legacy-root fallback"
3713                );
3714                let mut config = Self::create_default();
3715                if apply_env {
3716                    config.apply_env_overrides();
3717                }
3718                if publish {
3719                    config.publish_env_vars();
3720                }
3721                return config;
3722            }
3723        }
3724
3725        // Finish any shared manifest-committed credential extraction before
3726        // reading even one member of the transaction, then plan only the
3727        // provider/MCP/root domains. The optional broker has its own planner so
3728        // malformed broker metadata cannot suppress main configuration loading.
3729        let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3730            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3731            .map_err(|error| {
3732                tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3733                error
3734            })
3735            .is_ok();
3736        let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3737            .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3738            .map_err(|error| {
3739                tracing::warn!(error = %error, "cluster credential migration unavailable");
3740                error
3741            })
3742            .is_ok();
3743
3744        // A recovery performed by either legacy credential planner may have
3745        // completed the modular split. Reclassify immediately before touching
3746        // config.json so a pending split cannot race this compatibility load
3747        // back into legacy authority.
3748        match crate::modular_authority_boundary_present(&data_dir) {
3749            Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3750            Ok(false) => {}
3751            Err(error) => {
3752                tracing::warn!(
3753                    error = %error,
3754                    "modular configuration boundary is unavailable; refusing legacy-root fallback"
3755                );
3756                let mut config = Self::create_default();
3757                if apply_env {
3758                    config.apply_env_overrides();
3759                }
3760                if publish {
3761                    config.publish_env_vars();
3762                }
3763                return config;
3764            }
3765        }
3766
3767        let config_path = data_dir.join("config.json");
3768
3769        let mut config = if config_path.exists() {
3770            if let Ok(content) = std::fs::read_to_string(&config_path) {
3771                Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3772                    // Don't silently discard the user's config on corruption.
3773                    // Quarantine the unparseable file, then recover the MOST recent
3774                    // intent in order: (1) SALVAGE the still-valid fields from the
3775                    // corrupt file (a single bad field shouldn't drop everything),
3776                    // (2) the last-known-good config.json.bak, (3) defaults.
3777                    // #37 / #135. Tag the recovered config with a
3778                    // ConfigRecoveryStatus (unconfirmed) so save_to_dir refuses to
3779                    // overwrite config.json until the caller confirms — the
3780                    // quarantined original stays hand-recoverable until then. #153.
3781                    tracing::warn!(
3782                        "Failed to parse config.json ({}); quarantining it and attempting recovery",
3783                        e
3784                    );
3785                    let quarantine_path = quarantine_corrupt_config(&config_path);
3786                    let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3787                        .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3788                        .or_else(|| {
3789                            Self::load_backup(&data_dir).map(|(cfg, generation)| {
3790                                (cfg, ConfigRecoverySource::Backup { generation })
3791                            })
3792                        })
3793                        .unwrap_or_else(|| {
3794                            tracing::warn!(
3795                                "Could not salvage and no usable config.json.bak; using defaults"
3796                            );
3797                            (Self::create_default(), ConfigRecoverySource::Defaults)
3798                        });
3799                    recovered.recovery_status = Some(ConfigRecoveryStatus {
3800                        source,
3801                        quarantine_path,
3802                        confirmed: false,
3803                    });
3804                    recovered
3805                })
3806            } else {
3807                Self::create_default()
3808            }
3809        } else {
3810            Self::create_default()
3811        };
3812
3813        // Phase-1 registrar migration: an existing sidecar is authoritative;
3814        // when absent, retain the legacy inline value loaded from config.json.
3815        // A malformed sidecar is never rewritten during load and the inline
3816        // value remains available, preventing a bad independent edit from
3817        // erasing the user's last usable configuration.
3818        let mut memory_module = config.memory.clone();
3819        match memory_module.load_sync(&data_dir) {
3820            Ok(true) => config.memory = memory_module,
3821            Ok(false) => {}
3822            Err(error) => tracing::warn!(
3823                "Failed to load memory.json; using legacy config.json memory: {error}"
3824            ),
3825        }
3826        let mut subagents_module = config.subagents.clone();
3827        match subagents_module.load_sync(&data_dir) {
3828            Ok(true) => config.subagents = subagents_module,
3829            Ok(false) => {}
3830            Err(error) => tracing::warn!(
3831                "Failed to load subagents.json; using legacy config.json subagents: {error}"
3832            ),
3833        }
3834        if provider_mcp_ready {
3835            let mut providers_module = config.providers.clone();
3836            match providers_module.load_sync(&data_dir) {
3837                Ok(true) => config.providers = providers_module,
3838                Ok(false) => {}
3839                Err(error) => tracing::warn!(
3840                    "Failed to load providers.json; using legacy config.json providers: {error}"
3841                ),
3842            }
3843        }
3844
3845        // Decrypt encrypted proxy auth into in-memory plaintext form.
3846        config.hydrate_proxy_auth_from_encrypted();
3847        if provider_mcp_ready {
3848            if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
3849                tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3850                config.proxy_auth = None;
3851            }
3852        }
3853        // Decrypt encrypted provider API keys into in-memory plaintext form.
3854        config.hydrate_provider_api_keys_from_encrypted();
3855        // Decrypt encrypted provider-instance API keys into in-memory plaintext form.
3856        config.hydrate_provider_instance_api_keys_from_encrypted();
3857        // Decrypt encrypted MCP secrets into in-memory plaintext form.
3858        config.hydrate_mcp_secrets_from_encrypted();
3859        if provider_mcp_ready {
3860            if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
3861                tracing::warn!(error = %error, "provider credential hydration unavailable");
3862            }
3863            if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
3864                tracing::warn!(error = %error, "MCP credential hydration unavailable");
3865            }
3866        }
3867        // Decrypt encrypted env vars into in-memory plaintext form.
3868        config.hydrate_env_vars_from_encrypted();
3869        if provider_mcp_ready {
3870            if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
3871                tracing::warn!(error = %error, "env credential hydration unavailable");
3872                for entry in &mut config.env_vars {
3873                    if entry.secret {
3874                        entry.value.clear();
3875                    }
3876                }
3877            }
3878        }
3879        // Cluster migration is deliberately independent from provider/MCP
3880        // readiness. A malformed optional fabric fails only cluster runtime
3881        // authentication, while missing/corrupt refs never fall back to legacy
3882        // ciphertext or an unauthenticated SSH attempt.
3883        if cluster_ready {
3884            if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
3885                tracing::warn!(error = %error, "cluster credential hydration unavailable");
3886                config.clear_cluster_runtime_credentials();
3887            }
3888        } else {
3889            config.clear_cluster_runtime_credentials();
3890        }
3891        // Decrypt the encrypted broker token into in-memory plaintext.
3892        config.hydrate_broker_token_from_encrypted();
3893        // Decrypt encrypted notification-channel secrets into in-memory plaintext.
3894        config.hydrate_notifications_from_encrypted();
3895        if provider_mcp_ready {
3896            if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
3897                tracing::warn!(error = %error, "notification credential hydration unavailable");
3898                config.notifications.ntfy.token = None;
3899                config.notifications.bark.device_key = None;
3900            }
3901        } else {
3902            // A pending or unreadable credential migration means the isolated
3903            // store is not authoritative yet. Legacy plaintext/ciphertext may
3904            // remain on disk for recovery, but notification sinks must not use
3905            // it in this process.
3906            config.notifications.ntfy.token = None;
3907            config.notifications.bark.device_key = None;
3908        }
3909        // Merge the standalone connect.json (#455) onto `config.connect`,
3910        // migrating a legacy inline `connect` key from config.json (#453
3911        // state) when present. MUST run before the token hydration below so
3912        // it decrypts the post-merge ciphertext, not a stale/legacy copy.
3913        config.merge_connect_config(&data_dir);
3914        // One-time (idempotent) sweep of the rotated config.json.bak[.N]
3915        // generations for a legacy embedded `connect` sub-tree left behind by
3916        // a pre-#455 build (#468, follow-up to #457). Independent of whether
3917        // `merge_connect_config` just migrated the CURRENT config.json above —
3918        // an instance that was already migrated by an earlier run of this
3919        // binary has a clean config.json today but may still carry the
3920        // legacy key in an untouched `.bak`/`.bak.1`/`.bak.2` generation, since
3921        // backup rotation only overwrites those on a fresh SAVE. Runs on every
3922        // load but is cheap and a no-op once every generation has been swept.
3923        scrub_legacy_connect_from_config_backups(&data_dir);
3924        // Decrypt encrypted bamboo-connect platform tokens into in-memory plaintext.
3925        config.hydrate_connect_platform_tokens_from_encrypted();
3926        if provider_mcp_ready {
3927            if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
3928                tracing::warn!(error = %error, "connect credential hydration unavailable");
3929                for platform in &mut config.connect.platforms {
3930                    platform.token = None;
3931                    platform.app_secret = None;
3932                }
3933            }
3934        }
3935        if provider_mcp_ready {
3936            if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
3937                tracing::warn!(error = %error, "access-control credential hydration unavailable");
3938                config.clear_access_control_runtime_verifiers();
3939            }
3940        } else {
3941            config.clear_access_control_runtime_verifiers();
3942        }
3943        config.normalize_tool_settings();
3944        config.normalize_skill_settings();
3945        config.normalize_plugin_trust_settings();
3946
3947        // Legacy: `data_dir` is no longer a persisted config field. The data directory is
3948        // derived from runtime (BAMBOO_DATA_DIR or `${HOME}/.bamboo`).
3949        config.extra.remove("data_dir");
3950
3951        // Apply environment variable overrides (highest priority). Skipped by
3952        // one-shot CLI writers (`bamboo init` / `config set`) so transient
3953        // `BAMBOO_*` values are never baked into the persisted config.json.
3954        if apply_env {
3955            config.apply_env_overrides();
3956        }
3957
3958        // Publish env vars to the global cache so Bash tools can inject them —
3959        // ONLY when the caller owns that cache. Non-owning readers pass
3960        // publish=false so they don't clobber the server's live env-var cache.
3961        if publish {
3962            config.publish_env_vars();
3963        }
3964
3965        config
3966    }
3967
3968    /// Apply `BAMBOO_*` environment overrides (highest priority) onto a loaded
3969    /// config. Factored out so one-shot writers can skip it (see
3970    /// [`Config::from_data_dir_without_env`]).
3971    fn apply_env_overrides(&mut self) {
3972        if let Ok(port) = std::env::var("BAMBOO_PORT") {
3973            if let Ok(port) = port.parse() {
3974                self.server.port = port;
3975            }
3976        }
3977
3978        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
3979            self.server.bind = bind;
3980        }
3981
3982        // Note: BAMBOO_DATA_DIR already handled by the caller. In instance
3983        // mode the override may name either an exact instance id or a provider
3984        // type. Type matches are ordered by instance id so the result is
3985        // deterministic even for a multi-account configuration.
3986        if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
3987            let provider = provider.trim().to_string();
3988            if !provider.is_empty() {
3989                self.provider = provider.clone();
3990                if !self.provider_instances.is_empty() {
3991                    let selected = self
3992                        .provider_instances
3993                        .contains_key(&provider)
3994                        .then_some(provider.clone())
3995                        .or_else(|| {
3996                            let mut matching = self
3997                                .provider_instances
3998                                .iter()
3999                                .filter(|(_, instance)| {
4000                                    instance.enabled && instance.provider_type == provider
4001                                })
4002                                .map(|(id, _)| id.clone())
4003                                .collect::<Vec<_>>();
4004                            matching.sort();
4005                            matching.into_iter().next()
4006                        });
4007                    self.default_provider_instance = selected;
4008                }
4009            }
4010        }
4011
4012        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
4013            self.headless_auth = parse_bool_env(&headless);
4014        }
4015
4016        if let Ok(project_prompt_injection) =
4017            std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
4018        {
4019            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4020            memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
4021        }
4022
4023        if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
4024            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4025            memory.relevant_recall = parse_bool_env(&relevant_recall);
4026        }
4027
4028        if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4029            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4030            memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4031        }
4032
4033        if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4034            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4035            memory.project_first_dream = parse_bool_env(&project_first_dream);
4036        }
4037
4038        // Per-provider API keys from the environment (highest priority). Lets a
4039        // 12-factor / secret-manager / --env-file / k8s-Secret deploy supply the
4040        // key at runtime instead of baking a plaintext `api_key` into a mounted
4041        // config.json. The `api_key_from_env` flag keeps `refresh_provider_api_keys_encrypted`
4042        // from re-encrypting these keys into `api_key_encrypted` on a later save,
4043        // so an env key is never persisted to disk. (#253)
4044        if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4045            let key = key.trim();
4046            if !key.is_empty() {
4047                if self.provider_instances.is_empty() {
4048                    let openai = self
4049                        .providers
4050                        .openai
4051                        .get_or_insert_with(OpenAIConfig::default);
4052                    openai.api_key = key.to_string();
4053                    openai.api_key_from_env = true;
4054                } else {
4055                    self.apply_provider_instance_env_key("openai", key);
4056                }
4057            }
4058        }
4059        if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4060            let key = key.trim();
4061            if !key.is_empty() {
4062                if self.provider_instances.is_empty() {
4063                    let anthropic = self
4064                        .providers
4065                        .anthropic
4066                        .get_or_insert_with(AnthropicConfig::default);
4067                    anthropic.api_key = key.to_string();
4068                    anthropic.api_key_from_env = true;
4069                } else {
4070                    self.apply_provider_instance_env_key("anthropic", key);
4071                }
4072            }
4073        }
4074        if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4075            let key = key.trim();
4076            if !key.is_empty() {
4077                if self.provider_instances.is_empty() {
4078                    let gemini = self
4079                        .providers
4080                        .gemini
4081                        .get_or_insert_with(GeminiConfig::default);
4082                    gemini.api_key = key.to_string();
4083                    gemini.api_key_from_env = true;
4084                } else {
4085                    self.apply_provider_instance_env_key("gemini", key);
4086                }
4087            }
4088        }
4089    }
4090
4091    fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4092        for instance in self.provider_instances.values_mut().filter(|instance| {
4093            instance.provider_type == provider_type
4094                && crate::provider_instance_api_key_from_env(instance)
4095        }) {
4096            instance.api_key = key.to_string();
4097            instance.api_key_encrypted = None;
4098        }
4099    }
4100
4101    /// Apply runtime-only `BAMBOO_*` overrides to a config assembled by the
4102    /// modular facade. Facade callers use this after durable section snapshots
4103    /// and credential references have been materialized; one-shot writers keep
4104    /// using the no-env load path so overrides are never persisted.
4105    pub fn apply_runtime_env_overrides(&mut self) {
4106        self.apply_env_overrides();
4107    }
4108
4109    /// Load config from disk AND publish its env vars to the process-global cache
4110    /// (so Bash tools inject them). For the context that OWNS that cache — the
4111    /// server bootstrap. Library / secondary readers that only need to read a
4112    /// value must use [`Config::from_data_dir_without_publish`] instead, or they
4113    /// will clobber the server's live cache with stale disk data (#38 / #40).
4114    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4115        Self::from_data_dir_impl(data_dir, true, true)
4116    }
4117
4118    /// Load config from disk WITHOUT publishing env vars to the global cache.
4119    /// For non-owning readers (e.g. permission storage) that just need a config
4120    /// value and must not clobber the live env-var cache. #40.
4121    pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4122        Self::from_data_dir_impl(data_dir, false, true)
4123    }
4124
4125    /// Load config from disk WITHOUT applying `BAMBOO_*` env-var overrides and
4126    /// WITHOUT publishing to the global cache. For one-shot CLI writers
4127    /// (`bamboo init` / `config set`) that immediately re-save: applying env
4128    /// overrides here would bake transient values (port/bind/provider/memory
4129    /// flags) permanently into config.json. Same corruption-recovery + default
4130    /// fallback as the normal load.
4131    pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4132        Self::from_data_dir_impl(data_dir, false, false)
4133    }
4134
4135    /// Merge the standalone `connect.json` (#455) onto `self.connect`, the
4136    /// load-side counterpart of [`save_connect_config`]. Called once per load,
4137    /// BEFORE [`Config::hydrate_connect_platform_tokens_from_encrypted`] runs,
4138    /// so hydration decrypts the POST-merge ciphertext rather than a stale
4139    /// copy still embedded in `config.json`.
4140    ///
4141    /// - `connect.json` present & parseable: authoritative — OVERWRITES
4142    ///   whatever `self.connect` currently holds. If `self.connect` was ALSO
4143    ///   non-empty (a legacy inline `connect` key still in config.json, e.g.
4144    ///   #453-era state, or written by an older binary), that's a stale
4145    ///   duplicate: log a warning and proactively strip the superseded key
4146    ///   from config.json now (#457) rather than waiting for the next
4147    ///   natural save — cheap, and consistent with not spreading token
4148    ///   ciphertext across files.
4149    /// - `connect.json` present but corrupt/unparsable: fail SAFE for this
4150    ///   security-sensitive feature. Log an error, quarantine the bad file to
4151    ///   `connect.json.bak` (best-effort), and continue with an EMPTY
4152    ///   `ConnectConfig` — never falls back to a legacy config.json copy.
4153    /// - `connect.json` absent & `self.connect` non-empty (pure legacy
4154    ///   state): migrate proactively. Adopt the legacy value (already parsed
4155    ///   into `self`) and persist it: strip the `connect` key from
4156    ///   config.json and write connect.json (#457 — NOT a full
4157    ///   [`Config::save_to_dir`], which would re-encrypt every OTHER secret
4158    ///   in config.json and rotate its backups as a load-time side effect,
4159    ///   even for a read-only command like `bamboo config get`), logged at
4160    ///   info.
4161    /// - `connect.json` absent & `self.connect` empty: nothing to do.
4162    fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4163        let connect_path = data_dir.join("connect.json");
4164        match std::fs::read_to_string(&connect_path) {
4165            Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4166                Ok(connect) => {
4167                    let legacy_key_present = !connect_config_is_empty(&self.connect);
4168                    if legacy_key_present {
4169                        tracing::warn!(
4170                            "config.json still has a legacy `connect` key alongside \
4171                             connect.json; connect.json takes precedence — dropping the \
4172                             stale key from config.json now"
4173                        );
4174                    }
4175                    self.connect = connect;
4176                    if legacy_key_present {
4177                        strip_legacy_connect_key_from_config_json(data_dir);
4178                    }
4179                }
4180                Err(e) => {
4181                    tracing::error!(
4182                        "Failed to parse {:?} ({}); continuing with an empty (inert) \
4183                         connect config instead of falling back to a legacy config.json copy",
4184                        connect_path,
4185                        e
4186                    );
4187                    quarantine_corrupt_connect(&connect_path);
4188                    self.connect = ConnectConfig::default();
4189                }
4190            },
4191            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4192                if !connect_config_is_empty(&self.connect) {
4193                    tracing::info!(
4194                        "Migrating legacy `connect` config from config.json to a \
4195                         standalone connect.json"
4196                    );
4197                    // Narrow migration write (#457): strip only the `connect` key
4198                    // from config.json and write connect.json directly, instead of
4199                    // routing through a full `save_to_dir` (see doc comment above).
4200                    strip_legacy_connect_key_from_config_json(data_dir);
4201                    if let Err(e) = save_connect_config(&self.connect, data_dir) {
4202                        tracing::error!("Failed to write connect.json during migration: {}", e);
4203                    }
4204                }
4205            }
4206            Err(e) => {
4207                tracing::error!(
4208                    "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4209                    connect_path,
4210                    e
4211                );
4212                self.connect = ConnectConfig::default();
4213            }
4214        }
4215    }
4216
4217    /// Deserialize config JSON and run the in-memory hydration + normalization
4218    /// chain. Shared by the primary load and the backup-recovery path (#37).
4219    fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4220        serde_json::from_str::<Config>(content).map(|mut config| {
4221            config.hydrate_proxy_auth_from_encrypted();
4222            config.hydrate_provider_api_keys_from_encrypted();
4223            config.hydrate_provider_instance_api_keys_from_encrypted();
4224            config.hydrate_mcp_secrets_from_encrypted();
4225            config.hydrate_env_vars_from_encrypted();
4226            config.hydrate_cluster_fabric_from_encrypted();
4227            config.hydrate_broker_token_from_encrypted();
4228            config.hydrate_notifications_from_encrypted();
4229            config.hydrate_connect_platform_tokens_from_encrypted();
4230            config.normalize_tool_settings();
4231            config.normalize_skill_settings();
4232            config
4233        })
4234    }
4235
4236    /// Try to recover from the rotated `config.json.bak[.N]` generations (each a
4237    /// last-known-good written before a save) when the primary `config.json` is
4238    /// corrupt. Walks newest -> oldest and returns the first that parses (paired
4239    /// with its generation index, 0 == `.bak`, for [`ConfigRecoverySource::Backup`]);
4240    /// `None` if every generation is missing or also unparseable. #37 / #135.
4241    fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4242        let config_path = data_dir.join("config.json");
4243        for gen in 0..BAK_GENERATIONS {
4244            let backup = backup_path_for(&config_path, gen);
4245            let Ok(content) = std::fs::read_to_string(&backup) else {
4246                continue;
4247            };
4248            match Self::parse_and_hydrate(&content) {
4249                Ok(config) => {
4250                    tracing::info!("Recovered configuration from {:?}", backup);
4251                    return Some((config, gen));
4252                }
4253                Err(e) => {
4254                    tracing::warn!(
4255                        "Backup {:?} is unparseable ({}); trying an older generation",
4256                        backup,
4257                        e
4258                    );
4259                }
4260            }
4261        }
4262        None
4263    }
4264
4265    /// Largest corrupt-object key count we'll attempt to salvage. The overlay loop
4266    /// is O(keys) full-Config deserializes over a growing object (the `extra`
4267    /// catch-all absorbs unknown keys), i.e. O(n²) on a pathological file; cap it
4268    /// so a junk-key-flooded config.json can't stall a load. A real config has a
4269    /// few dozen top-level keys, so this only ever trips on garbage.
4270    const SALVAGE_MAX_KEYS: usize = 512;
4271
4272    /// Best-effort PARTIAL salvage of a corrupt `config.json` (#135): parse it as a
4273    /// generic JSON object and overlay each top-level field onto the richest
4274    /// known-good baseline — the last-known-good `config.json.bak` if present, else
4275    /// a fresh default — keeping only the fields that still yield a valid [`Config`].
4276    /// A single bad field (wrong type, malformed section, …) then keeps the
4277    /// baseline's value instead of discarding ALL the user's other settings.
4278    ///
4279    /// Overlaying onto `.bak` (rather than defaults) means the result is the
4280    /// best-of-both: the backup's complete recent-good state PLUS the corrupt
4281    /// file's still-valid newer edits on top — so salvage is never worse than the
4282    /// plain `.bak` fallback, removing the "sparse salvage defeats a rich backup"
4283    /// hazard. Tried BEFORE the bare `.bak` fallback.
4284    ///
4285    /// Returns the hydrated salvaged config, or `None` when the corrupt file isn't
4286    /// even a JSON object (nothing field-wise to salvage) so the caller falls
4287    /// through to `.bak` / defaults.
4288    ///
4289    /// NOTE: the per-field overlay guarantees a VALID `Config`, not a *maximal* or
4290    /// attribution-perfect one. Deterministic alphabetical key order (serde_json is
4291    /// BTreeMap-backed, no `preserve_order`) means a rename/alias pair like
4292    /// `mcp`/`mcpServers` can drop the second-seen even if it'd be valid alone — the
4293    /// outcome is still a valid config, just not necessarily the richest possible.
4294    ///
4295    /// Returns the hydrated salvaged config paired with the top-level keys that
4296    /// were actually recovered from the corrupt document (used to populate
4297    /// [`ConfigRecoverySource::Salvaged`]).
4298    fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4299        // Must at least be a JSON object; otherwise there's nothing field-wise to
4300        // salvage (a truncated/garbage file just falls through to .bak/defaults).
4301        let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4302        let corrupt_obj = corrupt.as_object()?;
4303        if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4304            tracing::warn!(
4305                "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4306                corrupt_obj.len(),
4307                Self::SALVAGE_MAX_KEYS
4308            );
4309            return None;
4310        }
4311
4312        // Overlay onto the richest known-good baseline: the last-known-good backup
4313        // if it parses, else a fresh default. This makes salvage >= the plain .bak
4314        // fallback in every case.
4315        let mut base = Self::load_backup(data_dir)
4316            .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4317            .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4318        let base_obj = base.as_object_mut()?;
4319
4320        let mut salvaged: Vec<String> = Vec::new();
4321        for (key, value) in corrupt_obj {
4322            let previous = base_obj.insert(key.clone(), value.clone());
4323            // Keep the field iff the WHOLE config still deserializes with it
4324            // overlaid — base is valid before each step, so a failure isolates THIS
4325            // field as the corrupt one (and inter-field constraints are respected).
4326            if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4327                salvaged.push(key.clone());
4328            } else {
4329                match previous {
4330                    Some(prev) => {
4331                        base_obj.insert(key.clone(), prev);
4332                    }
4333                    None => {
4334                        base_obj.remove(key);
4335                    }
4336                }
4337            }
4338        }
4339
4340        tracing::warn!(
4341            "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4342             last-known-good/default value",
4343            salvaged.len(),
4344            salvaged.join(", ")
4345        );
4346
4347        // Re-serialize the rebuilt (all-valid) object and run it back through the
4348        // normal parse+hydrate path so secret-decryption / normalization match a
4349        // clean load exactly.
4350        let rebuilt = serde_json::to_string(&base).ok()?;
4351        Self::parse_and_hydrate(&rebuilt)
4352            .ok()
4353            .map(|config| (config, salvaged))
4354    }
4355
4356    /// Get the effective default model for the currently active provider.
4357    ///
4358    /// When `features.provider_model_ref` is enabled, reads from `defaults.chat`
4359    /// before falling back to legacy provider-specific config.
4360    ///
4361    /// Note: for most providers this is a required config value (returns None when absent).
4362    /// Copilot has a built-in fallback when no model is configured.
4363    pub fn get_model(&self) -> Option<String> {
4364        if self.features.provider_model_ref {
4365            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4366                return Some(model_ref.model.clone());
4367            }
4368        }
4369        let provider = self.effective_default_provider();
4370        if let Some(instance) = self.provider_instances.get(provider) {
4371            return instance
4372                .model
4373                .clone()
4374                .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4375        }
4376        match provider {
4377            "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4378            "anthropic" => self
4379                .providers
4380                .anthropic
4381                .as_ref()
4382                .and_then(|c| c.model.clone()),
4383            "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4384            "copilot" => Some(
4385                self.providers
4386                    .copilot
4387                    .as_ref()
4388                    .and_then(|c| c.model.clone())
4389                    .unwrap_or_else(|| "gpt-4o".to_string()),
4390            ),
4391            _ => None,
4392        }
4393    }
4394
4395    /// Get the fast/cheap model for the currently active provider.
4396    ///
4397    /// When `features.provider_model_ref` is enabled, reads from `defaults.fast`
4398    /// before falling back to legacy provider-specific config.
4399    ///
4400    /// Used for lightweight tasks like title generation and summarization.
4401    /// Falls back to `get_model()` when no fast_model is configured.
4402    pub fn get_fast_model(&self) -> Option<String> {
4403        if self.features.provider_model_ref {
4404            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4405                return Some(model_ref.model.clone());
4406            }
4407        }
4408        let provider = self.effective_default_provider();
4409        let fast = if let Some(instance) = self.provider_instances.get(provider) {
4410            instance.fast_model.clone()
4411        } else {
4412            match provider {
4413                "openai" => self
4414                    .providers
4415                    .openai
4416                    .as_ref()
4417                    .and_then(|c| c.fast_model.clone()),
4418                "anthropic" => self
4419                    .providers
4420                    .anthropic
4421                    .as_ref()
4422                    .and_then(|c| c.fast_model.clone()),
4423                "gemini" => self
4424                    .providers
4425                    .gemini
4426                    .as_ref()
4427                    .and_then(|c| c.fast_model.clone()),
4428                "copilot" => self
4429                    .providers
4430                    .copilot
4431                    .as_ref()
4432                    .and_then(|c| c.fast_model.clone()),
4433                _ => None,
4434            }
4435        };
4436        fast.or_else(|| self.get_model())
4437    }
4438
4439    /// Get the configured task summarization model.
4440    ///
4441    /// When `features.provider_model_ref` is enabled, reads from
4442    /// `defaults.task_summary` before falling back through
4443    /// `defaults.fast` → `defaults.chat`.
4444    ///
4445    /// This is used for conversation/task summarization and context compression.
4446    pub fn get_task_summary_model(&self) -> Option<String> {
4447        if self.features.provider_model_ref {
4448            if let Some(model_ref) = self
4449                .defaults
4450                .as_ref()
4451                .and_then(|d| d.task_summary.as_ref())
4452                .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4453                .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4454            {
4455                return Some(model_ref.model.clone());
4456            }
4457        }
4458
4459        self.get_fast_model()
4460    }
4461
4462    /// Get the configured memory/background summarization model.
4463    ///
4464    /// When `features.provider_model_ref` is enabled, reads from
4465    /// `defaults.memory_background` before falling back through
4466    /// `defaults.fast` → `defaults.chat`.
4467    ///
4468    /// Legacy configuration follows the same dedicated → fast → chat order.
4469    pub fn get_memory_background_model(&self) -> Option<String> {
4470        if self.features.provider_model_ref {
4471            if let Some(model_ref) = self
4472                .defaults
4473                .as_ref()
4474                .and_then(|d| d.memory_background.as_ref())
4475            {
4476                return Some(model_ref.model.clone());
4477            }
4478            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4479                return Some(model_ref.model.clone());
4480            }
4481            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4482                return Some(model_ref.model.clone());
4483            }
4484        }
4485        let configured = self
4486            .memory
4487            .as_ref()
4488            .and_then(|memory| memory.background_model.as_ref())
4489            .map(|value| value.trim())
4490            .filter(|value| !value.is_empty())
4491            .map(ToString::to_string);
4492        configured.or_else(|| self.get_fast_model())
4493    }
4494
4495    /// Resolve the configured default work area path when present.
4496    ///
4497    /// This validates that the configured directory exists, but intentionally
4498    /// returns the stable expanded path rather than the platform-specific
4499    /// canonicalized path. On macOS, `canonicalize()` may rewrite `/var/...`
4500    /// to `/private/var/...`, which is correct at the filesystem layer but
4501    /// undesirable as a user-facing/config-derived workspace path.
4502    pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4503        let raw = self
4504            .default_work_area
4505            .as_ref()
4506            .and_then(|config| config.path.as_ref())
4507            .map(|value| value.trim())
4508            .filter(|value| !value.is_empty())?;
4509
4510        let candidate = expand_user_path(raw);
4511        if candidate.is_absolute() {
4512            let canonical = std::fs::canonicalize(&candidate).ok();
4513            return canonical
4514                .as_ref()
4515                .filter(|path| path.is_dir())
4516                .map(|_| candidate.clone())
4517                .or_else(|| candidate.is_dir().then_some(candidate));
4518        }
4519
4520        let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4521        let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4522        canonical
4523            .as_ref()
4524            .filter(|path| path.is_dir())
4525            .map(|_| from_bamboo_dir.clone())
4526            .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4527            .or_else(|| candidate.is_dir().then_some(candidate))
4528    }
4529
4530    /// Get the vision-capable model for the currently active provider.
4531    ///
4532    /// Used for image understanding tasks.
4533    /// Falls back to the fast model, then the chat model, when no vision model
4534    /// is configured.
4535    pub fn get_vision_model(&self) -> Option<String> {
4536        if self.features.provider_model_ref {
4537            if let Some(model_ref) = self
4538                .defaults
4539                .as_ref()
4540                .and_then(|defaults| defaults.vision.as_ref())
4541                .or_else(|| {
4542                    self.defaults
4543                        .as_ref()
4544                        .and_then(|defaults| defaults.fast.as_ref())
4545                })
4546                .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4547            {
4548                return Some(model_ref.model.clone());
4549            }
4550        }
4551        let provider = self.effective_default_provider();
4552        let vision = if let Some(instance) = self.provider_instances.get(provider) {
4553            instance.vision_model.clone()
4554        } else {
4555            match provider {
4556                "openai" => self
4557                    .providers
4558                    .openai
4559                    .as_ref()
4560                    .and_then(|c| c.vision_model.clone()),
4561                "anthropic" => self
4562                    .providers
4563                    .anthropic
4564                    .as_ref()
4565                    .and_then(|c| c.vision_model.clone()),
4566                "gemini" => self
4567                    .providers
4568                    .gemini
4569                    .as_ref()
4570                    .and_then(|c| c.vision_model.clone()),
4571                "copilot" => self
4572                    .providers
4573                    .copilot
4574                    .as_ref()
4575                    .and_then(|c| c.vision_model.clone()),
4576                _ => None,
4577            }
4578        };
4579        vision.or_else(|| self.get_fast_model())
4580    }
4581
4582    /// Get the default reasoning effort for the chat role.
4583    ///
4584    /// A role-specific preference wins over the provider-instance default.
4585    pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4586        if self.features.provider_model_ref {
4587            if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4588                return chat
4589                    .reasoning_effort
4590                    .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4591            }
4592        }
4593        self.reasoning_effort_for_key(self.effective_default_provider())
4594    }
4595
4596    /// Resolve the configured default reasoning effort for a provider routing key.
4597    ///
4598    /// The key may be a multi-instance provider id (for example `"copilot-work"`)
4599    /// or a legacy provider type (for example `"openai"`). In multi-instance mode
4600    /// the per-instance `reasoning_effort` lives under `provider_instances[<id>]`,
4601    /// so we resolve instance ids there first; otherwise we fall back to the
4602    /// legacy per-provider config. Both the execute path
4603    /// ([`crate`]'s `get_reasoning_effort_for_provider`) and the session-create
4604    /// path ([`Self::get_reasoning_effort`]) delegate here so the two cannot drift.
4605    pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4606        let trimmed = key.trim();
4607        if trimmed.is_empty() {
4608            return None;
4609        }
4610
4611        // Multi-instance mode: the routing key is an instance id.
4612        if let Some(instance) = self.provider_instances.get(trimmed) {
4613            return instance.reasoning_effort;
4614        }
4615
4616        // Legacy mode: the routing key is a provider type.
4617        match trimmed {
4618            "openai" => self
4619                .providers
4620                .openai
4621                .as_ref()
4622                .and_then(|c| c.reasoning_effort),
4623            "anthropic" => self
4624                .providers
4625                .anthropic
4626                .as_ref()
4627                .and_then(|c| c.reasoning_effort),
4628            "gemini" => self
4629                .providers
4630                .gemini
4631                .as_ref()
4632                .and_then(|c| c.reasoning_effort),
4633            "copilot" => self
4634                .providers
4635                .copilot
4636                .as_ref()
4637                .and_then(|c| c.reasoning_effort),
4638            "bodhi" => self
4639                .providers
4640                .bodhi
4641                .as_ref()
4642                .and_then(|c| c.reasoning_effort),
4643            _ => None,
4644        }
4645    }
4646
4647    /// Get exact disabled tool references for catalog-aware resolution.
4648    ///
4649    /// References remain exact here: catalog-aware filtering resolves an exact
4650    /// registered name before applying legacy/builtin alias fallback. Eagerly
4651    /// rewriting `apply_patch` to `Edit`, for example, would make an exact
4652    /// custom `apply_patch` registration indistinguishable from the builtin.
4653    pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4654        self.tools
4655            .disabled
4656            .iter()
4657            .map(|name| name.trim())
4658            .filter(|name| !name.is_empty())
4659            .map(str::to_string)
4660            .collect()
4661    }
4662
4663    /// Legacy normalized-name facade retained for source compatibility.
4664    ///
4665    /// New execution/catalog code must use [`Self::disabled_tool_references`]
4666    /// so an exact registered alias can be resolved before fallback.
4667    pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4668        self.disabled_tool_references()
4669            .into_iter()
4670            .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4671            .collect()
4672    }
4673
4674    /// Normalize tool settings (trim / dedupe / sort).
4675    pub fn normalize_tool_settings(&mut self) {
4676        self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4677    }
4678
4679    /// Get normalized disabled skill IDs.
4680    pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4681        self.skills
4682            .disabled
4683            .iter()
4684            .map(|id| id.trim())
4685            .filter(|id| !id.is_empty())
4686            .map(|id| id.to_string())
4687            .collect()
4688    }
4689
4690    /// Normalize skill settings (trim / dedupe / sort).
4691    pub fn normalize_skill_settings(&mut self) {
4692        self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4693    }
4694
4695    /// Normalize `plugin_trust.trusted_hosts` entries (trim / lowercase / drop
4696    /// empties) so a hand-edited `config.json` doesn't silently accumulate
4697    /// mixed-case or whitespace-padded entries. [`is_host_trusted`] itself
4698    /// already matches case-insensitively regardless of how an entry is
4699    /// stored, so this is defense in depth / a canonical on-disk form, not
4700    /// the source of the security fix — that's the host/path-component
4701    /// matching in [`is_host_trusted`] itself.
4702    pub fn normalize_plugin_trust_settings(&mut self) {
4703        self.plugin_trust.trusted_hosts = self
4704            .plugin_trust
4705            .trusted_hosts
4706            .iter()
4707            .map(|entry| entry.trim().to_ascii_lowercase())
4708            .filter(|entry| !entry.is_empty())
4709            .collect();
4710    }
4711
4712    /// Return the effective default provider key.
4713    ///
4714    /// Prefers `default_provider_instance` when set; falls back to the
4715    /// legacy `provider` string.
4716    pub fn effective_default_provider(&self) -> &str {
4717        self.default_provider_instance
4718            .as_deref()
4719            .unwrap_or(&self.provider)
4720    }
4721
4722    /// Whether provider instances are configured (new multi-instance path).
4723    pub fn has_provider_instances(&self) -> bool {
4724        !self.provider_instances.is_empty()
4725    }
4726
4727    /// Build a flat map of all env vars with non-empty values (for process injection).
4728    pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4729        self.env_vars
4730            .iter()
4731            .filter(|e| !e.value.trim().is_empty())
4732            .map(|e| (e.name.clone(), e.value.clone()))
4733            .collect()
4734    }
4735
4736    fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4737        self.env_vars
4738            .iter()
4739            .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4740            .map(|entry| PromptSafeEnvVarEntry {
4741                name: entry.name.clone(),
4742                secret: entry.secret,
4743                description: entry
4744                    .description
4745                    .as_ref()
4746                    .map(|value| value.trim().to_string())
4747                    .filter(|value| !value.is_empty()),
4748            })
4749            .collect()
4750    }
4751
4752    /// Update the global env vars cache (called on config load / reload).
4753    pub fn publish_env_vars(&self) {
4754        let map = self.env_vars_as_map();
4755
4756        #[cfg(any(test, feature = "test-utils"))]
4757        if crate::test_support::env_vars_cache_override_is_active() {
4758            crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4759            return;
4760        }
4761
4762        let mut env_guard = env_vars_cache().write().recover_poison();
4763        *env_guard = map;
4764
4765        let prompt_safe = self.prompt_safe_env_vars();
4766        let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4767        *prompt_guard = prompt_safe;
4768    }
4769
4770    /// Read the current env vars snapshot (called by Bash tool at process spawn time).
4771    pub fn current_env_vars() -> HashMap<String, String> {
4772        #[cfg(any(test, feature = "test-utils"))]
4773        if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4774            return env_vars;
4775        }
4776
4777        env_vars_cache().read().recover_poison().clone()
4778    }
4779
4780    /// Read the current prompt-safe env var snapshot (names + metadata only; no secret values).
4781    pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4782        #[cfg(any(test, feature = "test-utils"))]
4783        if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4784            return env_vars;
4785        }
4786
4787        prompt_safe_env_vars_cache().read().recover_poison().clone()
4788    }
4789
4790    /// Create a default configuration without loading from file
4791    fn create_default() -> Self {
4792        Self::from_parts(
4793            ConfigValues {
4794                http_proxy: String::new(),
4795                https_proxy: String::new(),
4796                proxy_auth: None,
4797                proxy_auth_encrypted: None,
4798                proxy_auth_credential_ref: None,
4799                headless_auth: false,
4800                run_budget: RunBudgetConfig::default(),
4801                stream_timeout: StreamTimeoutConfig::default(),
4802                context_management: ContextManagementConfig::default(),
4803                cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4804                provider: default_provider(),
4805                provider_instances: HashMap::new(),
4806                default_provider_instance: None,
4807                server: ServerConfig::default(),
4808                keyword_masking: KeywordMaskingConfig::default(),
4809                anthropic_model_mapping: AnthropicModelMapping::default(),
4810                gemini_model_mapping: GeminiModelMapping::default(),
4811                hooks: HooksConfig::default(),
4812                lifecycle_hooks: LifecycleHooksConfig::default(),
4813                tools: ToolsConfig::default(),
4814                skills: SkillsConfig::default(),
4815                env_vars: Vec::new(),
4816                default_work_area: None,
4817                worktrees: WorktreeSettings::default(),
4818                access_control: None,
4819                features: FeatureFlags::default(),
4820                defaults: None,
4821                mcp: bamboo_domain::mcp_config::McpConfig::default(),
4822                notifications: NotificationsConfig::default(),
4823                connect: ConnectConfig::default(),
4824                plugin_trust: PluginTrustConfig::default(),
4825                extra: BTreeMap::new(),
4826            },
4827            None,
4828            SubagentsConfig::default(),
4829            ProviderConfigs::default(),
4830        )
4831    }
4832
4833    /// Get the full server address (bind:port)
4834    pub fn server_addr(&self) -> String {
4835        format!("{}:{}", self.server.bind, self.server.port)
4836    }
4837
4838    /// Save configuration to disk
4839    pub fn save(&self) -> Result<()> {
4840        self.save_to_dir(default_data_dir())
4841    }
4842
4843    /// Persist only the memory module, leaving every other config file untouched.
4844    /// Legacy layouts only; modular layouts require a revisioned section update.
4845    pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4846        self.memory.save_sync(data_dir)
4847    }
4848
4849    /// Persist only the sub-agent module, leaving every other config file untouched.
4850    /// Legacy layouts only; modular layouts require a revisioned section update.
4851    pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4852        self.subagents.save_sync(data_dir)
4853    }
4854
4855    /// Persist only provider configuration. Provider plaintext keys are first
4856    /// refreshed into their encrypted at-rest representation.
4857    /// Legacy layouts only; modular layouts require a revisioned section update.
4858    pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
4859        crate::config_module::ensure_legacy_module_layout(data_dir)?;
4860        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4861        let mut config = self.clone();
4862        config.clear_legacy_provider_aliases_for_instance_mode();
4863        config.refresh_provider_api_keys_encrypted()?;
4864        config.providers.save_sync(data_dir)
4865    }
4866
4867    /// Build the metadata-only documents used by a provider credential
4868    /// transaction. Nothing is written here: the migration journal owns the
4869    /// durable commit and publishes both documents together with credentials.
4870    pub(crate) fn prepare_provider_transaction_documents(
4871        &self,
4872        provider_document: &[u8],
4873    ) -> Result<(Vec<u8>, Vec<u8>)> {
4874        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
4875        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
4876            anyhow::bail!(
4877                "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
4878                status.source
4879            );
4880        }
4881
4882        let mut to_save = self.clone();
4883        to_save.clear_legacy_provider_aliases_for_instance_mode();
4884        to_save.extra.remove("data_dir");
4885        to_save.extra.remove("model");
4886        to_save.refresh_encrypted_secrets()?;
4887        to_save.ensure_provider_instance_credentials_isolated()?;
4888        to_save.sanitize_mcp_credential_refs_for_disk();
4889        to_save.sanitize_env_vars_for_disk();
4890        to_save.sanitize_notifications_for_disk();
4891        to_save.sanitize_cluster_fabric_for_disk();
4892        to_save.assign_connect_platform_ids();
4893        to_save.normalize_tool_settings();
4894        to_save.normalize_skill_settings();
4895
4896        let mut root = durable_root_value(to_save.values.clone())
4897            .context("Failed to serialize root config DTO to JSON")?;
4898        if let Some(object) = root.as_object_mut() {
4899            object.remove("connect");
4900        }
4901        let root = serde_json::to_vec_pretty(&root)?;
4902
4903        let mut providers = to_save.providers.0.clone();
4904        macro_rules! sanitize_provider {
4905            ($field:ident) => {
4906                if let Some(provider) = providers.$field.as_mut() {
4907                    if !provider.api_key.trim().is_empty()
4908                        && !provider.api_key_from_env
4909                        && provider.credential_ref.is_none()
4910                    {
4911                        anyhow::bail!(
4912                            "provider secret requires credential transaction before persistence"
4913                        );
4914                    }
4915                    provider.api_key_encrypted = None;
4916                }
4917            };
4918        }
4919        sanitize_provider!(openai);
4920        sanitize_provider!(anthropic);
4921        sanitize_provider!(gemini);
4922        if let Some(provider) = providers.bodhi.as_mut() {
4923            if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
4924                anyhow::bail!("provider secret requires credential transaction before persistence");
4925            }
4926            provider.api_key_encrypted = None;
4927        }
4928
4929        let existing_provider_value = if provider_document.is_empty() {
4930            None
4931        } else {
4932            Some(
4933                serde_json::from_slice::<Value>(provider_document)
4934                    .context("provider metadata document is invalid")?,
4935            )
4936        };
4937        let provider_value = match existing_provider_value {
4938            Some(Value::Object(mut envelope))
4939                if envelope.contains_key("schema_version")
4940                    || envelope.contains_key("revision")
4941                    || envelope.contains_key("data") =>
4942            {
4943                let revision = envelope
4944                    .get("revision")
4945                    .and_then(Value::as_u64)
4946                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4947                let schema_version = envelope
4948                    .get("schema_version")
4949                    .and_then(Value::as_u64)
4950                    .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
4951                if schema_version != 1 || !envelope.contains_key("data") {
4952                    anyhow::bail!("provider revision envelope is unsupported");
4953                }
4954                let revision = revision
4955                    .checked_add(1)
4956                    .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
4957                envelope.insert("revision".into(), Value::from(revision));
4958                envelope.insert("data".into(), serde_json::to_value(providers)?);
4959                Value::Object(envelope)
4960            }
4961            Some(_) | None => serde_json::to_value(providers)?,
4962        };
4963        Ok((root, serde_json::to_vec_pretty(&provider_value)?))
4964    }
4965
4966    /// The pending config-corruption recovery, if `config.json` failed to
4967    /// parse on load and the recovery hasn't been confirmed yet. `None` on
4968    /// every clean load. #153.
4969    pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
4970        self.recovery_status.as_ref()
4971    }
4972
4973    /// Confirm a pending recovery, allowing the next [`Config::save`] /
4974    /// [`Config::save_to_dir`] to overwrite the quarantined-corrupt
4975    /// `config.json` with this recovered state. No-op if there's no pending
4976    /// recovery. Prefer [`Config::confirm_recovery_and_save_to_dir`], which
4977    /// also persists and clears the flag in one step. #153.
4978    pub fn confirm_recovery(&mut self) {
4979        if let Some(status) = self.recovery_status.as_mut() {
4980            status.confirmed = true;
4981        }
4982    }
4983
4984    /// Confirm a pending recovery AND persist it in one step: marks it
4985    /// confirmed (satisfying the [`Config::save_to_dir`] guard), writes the
4986    /// recovered state to `config.json`, then clears `recovery_status`
4987    /// entirely — once this succeeds the config is no longer "pending
4988    /// confirmation", it's just the normal on-disk config. Errors (and
4989    /// leaves `recovery_status` untouched) if there's nothing pending, or if
4990    /// the save itself fails. #153.
4991    pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
4992        if self.recovery_status.is_none() {
4993            anyhow::bail!("No pending config-corruption recovery to confirm");
4994        }
4995        self.confirm_recovery();
4996        self.save_to_dir(data_dir)?;
4997        self.recovery_status = None;
4998        Ok(())
4999    }
5000
5001    /// Assign a stable [`ConnectPlatformConfig::id`] to every `connect.platforms`
5002    /// entry that doesn't already have one (#496).
5003    ///
5004    /// Migration-on-write: [`Config::save_to_dir`] always calls this on its
5005    /// internal save-copy before persisting, so every path that writes
5006    /// `connect.json` gets ids backfilled. Callers that mutate the *live*
5007    /// in-memory config as part of a save (e.g. the server's settings-PATCH
5008    /// handler) should also call this directly on that in-memory value
5009    /// before responding, so a client that echoes the response straight
5010    /// back round-trips the id immediately rather than only after the next
5011    /// reload/restart. Never called from load — a config that's never saved
5012    /// again (e.g. one sitting in an unconfirmed-recovery state, see #493)
5013    /// is never rewritten just to backfill ids. An entry that already has
5014    /// an id keeps it unchanged; ids are never reassigned or deduplicated
5015    /// once set.
5016    pub fn assign_connect_platform_ids(&mut self) {
5017        for platform in &mut self.connect.platforms {
5018            if platform.id.is_none() {
5019                platform.id = Some(uuid::Uuid::new_v4().to_string());
5020            }
5021        }
5022    }
5023
5024    /// Save configuration to disk under the provided data directory.
5025    ///
5026    /// Root configuration is stored as `{data_dir}/config.json`; extracted
5027    /// memory, sub-agent, and provider modules are stored in sibling sidecars.
5028    ///
5029    /// Refuses to write when this config carries an unconfirmed
5030    /// [`ConfigRecoveryStatus`] (#153) — i.e. it was recovered from a corrupt
5031    /// `config.json` and the recovery hasn't been confirmed — so a corrupt
5032    /// original a user might want to hand-fix is never silently clobbered by
5033    /// an auto-persisted recovery. Call [`Config::confirm_recovery`] (or
5034    /// [`Config::confirm_recovery_and_save_to_dir`]) first.
5035    pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5036        crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5037        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5038            anyhow::bail!(
5039                "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5040                 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5041                 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5042                status.source,
5043                status.quarantine_path,
5044            );
5045        }
5046        if self.proxy_auth_credential_ref.is_none()
5047            && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5048        {
5049            anyhow::bail!(
5050                "proxy auth requires the isolated credential transaction before persistence"
5051            );
5052        }
5053        if self.env_vars.iter().any(|entry| {
5054            entry.secret
5055                && entry.credential_ref.is_none()
5056                && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5057        }) {
5058            anyhow::bail!(
5059                "secret env vars require the isolated credential transaction before persistence"
5060            );
5061        }
5062        if [
5063            (
5064                &self.notifications.ntfy.token,
5065                &self.notifications.ntfy.token_encrypted,
5066                &self.notifications.ntfy.credential_ref,
5067            ),
5068            (
5069                &self.notifications.bark.device_key,
5070                &self.notifications.bark.device_key_encrypted,
5071                &self.notifications.bark.credential_ref,
5072            ),
5073        ]
5074        .iter()
5075        .any(|(plaintext, ciphertext, reference)| {
5076            reference.is_none()
5077                && (plaintext
5078                    .as_deref()
5079                    .is_some_and(|value| !value.trim().is_empty())
5080                    || ciphertext.is_some())
5081        }) {
5082            anyhow::bail!(
5083                "notification secrets require the isolated credential transaction before persistence"
5084            );
5085        }
5086
5087        if crate::modular_authority_boundary_present(&data_dir)
5088            .context("Failed to inspect modular configuration authority boundary")?
5089        {
5090            crate::ConfigFacade::open_or_migrate(&data_dir)
5091                .context("Failed to recover modular configuration before persistence")?;
5092            crate::persist_facade_effective_config(&data_dir, self)
5093                .context("Failed to persist modular configuration sections")?;
5094            return Ok(());
5095        }
5096
5097        if crate::section_layout_is_active(&data_dir)
5098            .context("Failed to inspect modular configuration layout")?
5099        {
5100            crate::persist_facade_effective_config(&data_dir, self)
5101                .context("Failed to persist modular configuration sections")?;
5102            return Ok(());
5103        }
5104
5105        let path = data_dir.join("config.json");
5106
5107        if let Some(parent) = path.parent() {
5108            std::fs::create_dir_all(parent)
5109                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5110        }
5111
5112        let mut to_save = self.clone();
5113        to_save.clear_legacy_provider_aliases_for_instance_mode();
5114        // Never persist `data_dir` into config.json (data dir is runtime-derived).
5115        to_save.extra.remove("data_dir");
5116        // Root-level `model` is deprecated; do not persist it.
5117        to_save.extra.remove("model");
5118        // `subagents.broker` is `#[serde(skip)]` (runtime-only, lives in its own
5119        // broker.json / embedded in-process) — nothing to encrypt or persist here.
5120        to_save.refresh_encrypted_secrets()?;
5121        to_save.ensure_provider_instance_credentials_isolated()?;
5122        to_save.sanitize_mcp_credential_refs_for_disk();
5123        to_save.sanitize_env_vars_for_disk();
5124        to_save.sanitize_notifications_for_disk();
5125        to_save.sanitize_cluster_fabric_for_disk();
5126        to_save.assign_connect_platform_ids();
5127        to_save.normalize_tool_settings();
5128        to_save.normalize_skill_settings();
5129
5130        // Split `connect` (#455) out of the config.json document: bamboo-connect
5131        // platform-bridge credentials (bot tokens, allowlists) get their own
5132        // sibling file, connect.json (written below), instead of living in
5133        // config.json — different sensitivity/lifecycle. The `connect` FIELD on
5134        // `Config` keeps its normal serde shape unchanged (still required by the
5135        // settings API / `preserve_masked_connect_secrets`, which operate on the
5136        // in-memory struct) — only the serialized DOCUMENT that becomes
5137        // config.json's bytes has the key stripped, and that's done on the
5138        // `serde_json::Value` here, not via `#[serde(skip)]` on the field.
5139        let mut config_value = durable_root_value(to_save.values.clone())
5140            .context("Failed to serialize root config DTO to JSON")?;
5141        if let Some(obj) = config_value.as_object_mut() {
5142            obj.remove("connect");
5143        }
5144        let content = serde_json::to_string_pretty(&config_value)
5145            .context("Failed to serialize config to JSON")?;
5146
5147        // Persist extracted modules before stripping their legacy inline
5148        // representation from config.json. If the process crashes or the root
5149        // rewrite fails during the first migration, the next load can still use
5150        // either the new sidecars or the untouched inline values. Do this before
5151        // rotating root backups so a sidecar error cannot consume backup history
5152        // for a root document that was never rewritten.
5153        to_save.memory.save_sync(&data_dir)?;
5154        to_save.subagents.save_sync(&data_dir)?;
5155        to_save.providers.save_sync(&data_dir)?;
5156
5157        // Back up the current on-disk config (last-known-good) before overwriting,
5158        // so corruption (a bad/partial write, external edit, disk issue) stays
5159        // recoverable via config.json.bak on the next load. Best-effort. Only
5160        // refresh the backup from a PARSEABLE config.json — otherwise a save right
5161        // after an in-memory recovery (where the on-disk config.json is still the
5162        // corrupt original) would clobber the good .bak with garbage. #37.
5163        if path.exists()
5164            && std::fs::read_to_string(&path)
5165                .ok()
5166                .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5167        {
5168            // Rotate the older generations down (.bak -> .bak.1 -> .bak.2 …) so a
5169            // few last-known-good snapshots survive, then snapshot the current
5170            // (parseable) config.json as the freshest .bak. #135.
5171            rotate_backups(&path, BAK_GENERATIONS);
5172            let backup = backup_path_for(&path, 0);
5173            let backup_result = std::fs::read(&path).and_then(|bytes| {
5174                let mut value: Value = serde_json::from_slice(&bytes)
5175                    .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5176                if sanitize_ref_backed_mcp_json(&mut value) {
5177                    let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5178                        std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5179                    })?;
5180                    write_atomic(&backup, &sanitized)
5181                } else {
5182                    std::fs::copy(&path, &backup).map(|_| ())
5183                }
5184            });
5185            if let Err(e) = backup_result {
5186                tracing::warn!("Failed to back up config.json before save: {}", e);
5187            }
5188            scrub_ref_backed_mcp_from_config_backups(&path);
5189        }
5190
5191        write_atomic(&path, content.as_bytes())
5192            .with_context(|| format!("Failed to write config file: {:?}", path))?;
5193
5194        save_connect_config(&to_save.connect, &data_dir)?;
5195
5196        Ok(())
5197    }
5198}
5199
5200/// Remove credential-ref-backed MCP values from a raw root document without
5201/// otherwise normalizing its compatibility shape. This is used for rotated
5202/// root backups as well as the typed disk DTO so a pre-fix root cannot keep a
5203/// duplicate secret alive for several more saves.
5204fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5205    let Some(object) = root.as_object_mut() else {
5206        return false;
5207    };
5208    let Some(mcp) = (if object.contains_key("mcpServers") {
5209        object.get_mut("mcpServers")
5210    } else {
5211        object.get_mut("mcp")
5212    }) else {
5213        return false;
5214    };
5215    let mut changed = false;
5216    if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5217        for server in servers {
5218            if let Some(object) = server.as_object_mut() {
5219                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5220                {
5221                    changed |= sanitize_ref_backed_mcp_transport(transport);
5222                }
5223            }
5224        }
5225    } else if let Some(servers) = mcp.as_object_mut() {
5226        for server in servers.values_mut() {
5227            if let Some(object) = server.as_object_mut() {
5228                changed |= sanitize_ref_backed_mcp_transport(object);
5229                if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5230                {
5231                    changed |= sanitize_ref_backed_mcp_transport(transport);
5232                }
5233            }
5234        }
5235    }
5236    changed
5237}
5238
5239fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5240    let mut changed = false;
5241    let env_names = object
5242        .get("env_credential_refs")
5243        .and_then(Value::as_object)
5244        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5245        .unwrap_or_default();
5246    for name in env_names {
5247        for field in ["env", "env_encrypted"] {
5248            if object
5249                .get_mut(field)
5250                .and_then(Value::as_object_mut)
5251                .and_then(|values| values.remove(&name))
5252                .is_some()
5253            {
5254                changed = true;
5255            }
5256        }
5257    }
5258    let header_names = object
5259        .get("header_credential_refs")
5260        .and_then(Value::as_object)
5261        .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5262        .unwrap_or_default();
5263    for name in header_names {
5264        for field in ["headers", "headers_encrypted"] {
5265            if object
5266                .get_mut(field)
5267                .and_then(Value::as_object_mut)
5268                .and_then(|values| values.remove(&name))
5269                .is_some()
5270            {
5271                changed = true;
5272            }
5273        }
5274    }
5275    if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5276        for header in headers {
5277            let Some(header) = header.as_object_mut() else {
5278                continue;
5279            };
5280            if header.get("credential_ref").is_some_and(Value::is_string) {
5281                changed |= header.remove("value").is_some();
5282                changed |= header.remove("value_encrypted").is_some();
5283            }
5284        }
5285    }
5286    changed
5287}
5288
5289fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5290    for generation in 0..BAK_GENERATIONS {
5291        let backup = backup_path_for(path, generation);
5292        let Ok(bytes) = std::fs::read(&backup) else {
5293            continue;
5294        };
5295        let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5296            continue;
5297        };
5298        if sanitize_ref_backed_mcp_json(&mut value) {
5299            if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5300                if let Err(error) = write_atomic(&backup, &sanitized) {
5301                    tracing::warn!(
5302                        "Failed to scrub MCP credentials from {:?}: {}",
5303                        backup,
5304                        error
5305                    );
5306                }
5307            }
5308        }
5309    }
5310}
5311
5312/// Persist `connect` (#455) to its own sibling file, `connect.json`, next to
5313/// config.json — the save-side counterpart of [`Config::merge_connect_config`].
5314///
5315/// Only writes when the config is non-empty OR the file already exists, so a
5316/// fresh/default install with no platforms configured never gets a
5317/// `connect.json` littering its data dir. Before an existing file is
5318/// overwritten, it's copied aside to a single `connect.json.bak` generation
5319/// (best-effort) — connect.json doesn't need config.json's multi-generation
5320/// rotation, one last-known-good snapshot is enough.
5321fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5322    let path = data_dir.join("connect.json");
5323    if connect_config_is_empty(connect) && !path.exists() {
5324        return Ok(());
5325    }
5326
5327    if path.exists() {
5328        let backup = path.with_extension("json.bak");
5329        if let Err(e) = std::fs::copy(&path, &backup) {
5330            tracing::warn!("Failed to back up connect.json before save: {}", e);
5331        }
5332    }
5333
5334    let content = serde_json::to_string_pretty(connect)
5335        .context("Failed to serialize connect config to JSON")?;
5336    write_atomic(&path, content.as_bytes())
5337        .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5338    Ok(())
5339}
5340
5341/// Remove the legacy inline `connect` key from `config.json` on disk, if
5342/// present — the narrow, load-side counterpart of the full-document rewrite
5343/// [`Config::save_to_dir`] would otherwise perform just to drop one stale
5344/// key. Used by [`Config::merge_connect_config`] both when adopting a
5345/// pure-legacy `connect` key (migration) and when a stale legacy key lingers
5346/// alongside an authoritative connect.json. #457.
5347///
5348/// Operates on the raw `serde_json::Value` read straight from disk — NOT on
5349/// the typed `Config` — so it touches nothing but the one key: no other
5350/// secret gets re-encrypted, and no `config.json.bak` generation gets
5351/// rotated, as a side effect of a load.
5352///
5353/// Best-effort: read/parse/write failures are logged, not propagated — this
5354/// runs as a side effect of `Config::new()` / load, which has no `Result` to
5355/// surface it through. A failure here just leaves the stale key in place
5356/// until the next natural save; connect.json (written separately) is already
5357/// authoritative in memory either way.
5358fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5359    let config_path = data_dir.join("config.json");
5360    let content = match std::fs::read_to_string(&config_path) {
5361        Ok(content) => content,
5362        Err(e) => {
5363            tracing::error!(
5364                "Failed to read config.json to strip legacy `connect` key: {}",
5365                e
5366            );
5367            return;
5368        }
5369    };
5370    let mut value: serde_json::Value = match serde_json::from_str(&content) {
5371        Ok(value) => value,
5372        Err(e) => {
5373            tracing::error!(
5374                "Failed to parse config.json to strip legacy `connect` key: {}",
5375                e
5376            );
5377            return;
5378        }
5379    };
5380    let Some(obj) = value.as_object_mut() else {
5381        return;
5382    };
5383    if obj.remove("connect").is_none() {
5384        // Nothing to strip (e.g. raced with a concurrent save that already
5385        // dropped it) — avoid an unnecessary rewrite.
5386        return;
5387    }
5388    let rewritten = match serde_json::to_string_pretty(&value) {
5389        Ok(rewritten) => rewritten,
5390        Err(e) => {
5391            tracing::error!(
5392                "Failed to serialize config.json after stripping legacy `connect` key: {}",
5393                e
5394            );
5395            return;
5396        }
5397    };
5398    if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5399        tracing::error!(
5400            "Failed to write config.json after stripping legacy `connect` key: {}",
5401            e
5402        );
5403    }
5404}
5405
5406/// Sweep the rotated `config.json.bak[.N]` generations for a legacy embedded
5407/// `connect` sub-tree that predates the #455 connect.json split, and strip it
5408/// in place. #468 (follow-up to #457).
5409///
5410/// `strip_legacy_connect_key_from_config_json` only ever rewrites the CURRENT
5411/// `config.json` — it never reaches into `.bak` generations, and the normal
5412/// backup-rotation path (see [`rotate_backups`]) only overwrites a `.bak[.N]`
5413/// slot as a side effect of a fresh SAVE. An instance that upgraded from a
5414/// pre-#455 build but rarely (or never) triggers a config save can therefore
5415/// carry the legacy, encrypted `connect` sub-tree — including bot tokens, an
5416/// immediately-usable remote-control credential — in an old backup generation
5417/// indefinitely, even after its live config.json has long since been
5418/// migrated.
5419///
5420/// Deliberately surgical, mirroring the `.bak` files' role as the user's
5421/// recovery net (#493's "backups are a low-sensitivity snapshot, don't fuss
5422/// with them" posture):
5423/// - a generation that doesn't exist, or that fails to parse as JSON, is
5424///   SKIPPED — logged, never deleted, never guessed at. Corrupt/foreign
5425///   content in a `.bak` slot is left exactly as found for hand inspection.
5426/// - a generation that parses but carries no `connect` key (the overwhelming
5427///   majority, especially on any instance that predates this fix by more
5428///   than `BAK_GENERATIONS` saves) is left COMPLETELY untouched — not even a
5429///   byte-identical rewrite — so its mtime and on-disk bytes survive.
5430/// - only a generation that actually parses AND carries the legacy key gets
5431///   rewritten, via the same key-removal-on-the-raw-`Value` + `write_atomic`
5432///   approach as `strip_legacy_connect_key_from_config_json`, so every other
5433///   byte of that snapshot (all other settings, formatting aside) survives.
5434///
5435/// Runs unconditionally on every load (not gated on the CURRENT config.json
5436/// still carrying the legacy key) specifically to catch already-migrated
5437/// installs whose backups predate this fix. Cheap: at most `BAK_GENERATIONS`
5438/// small file reads, and a genuine no-op (zero writes) once every generation
5439/// has been swept once. Best-effort like its sibling: failures are logged,
5440/// not propagated, since this runs as a side effect of `Config::new()` /
5441/// load, which has no `Result` to surface it through.
5442fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5443    let config_path = data_dir.join("config.json");
5444    for gen in 0..BAK_GENERATIONS {
5445        let backup = backup_path_for(&config_path, gen);
5446        let content = match std::fs::read_to_string(&backup) {
5447            Ok(content) => content,
5448            Err(e) => {
5449                if e.kind() != std::io::ErrorKind::NotFound {
5450                    tracing::warn!(
5451                        "Failed to read {:?} while scanning for legacy connect data ({}); \
5452                         leaving it untouched",
5453                        backup,
5454                        e
5455                    );
5456                }
5457                continue;
5458            }
5459        };
5460        let mut value: serde_json::Value = match serde_json::from_str(&content) {
5461            Ok(value) => value,
5462            Err(e) => {
5463                tracing::warn!(
5464                    "Skipping unparsable backup {:?} while scanning for legacy connect data \
5465                     ({}); left untouched (never deleted)",
5466                    backup,
5467                    e
5468                );
5469                continue;
5470            }
5471        };
5472        let Some(obj) = value.as_object_mut() else {
5473            // Not a JSON object (e.g. `null`/an array) — nothing to strip, and
5474            // not a shape we should try to rewrite. Leave it alone.
5475            continue;
5476        };
5477        if obj.remove("connect").is_none() {
5478            // No legacy key in this generation — skip without writing so the
5479            // file's bytes/mtime are left completely untouched.
5480            continue;
5481        }
5482        let rewritten = match serde_json::to_string_pretty(&value) {
5483            Ok(rewritten) => rewritten,
5484            Err(e) => {
5485                tracing::error!(
5486                    "Failed to serialize {:?} after stripping legacy connect data: {}",
5487                    backup,
5488                    e
5489                );
5490                continue;
5491            }
5492        };
5493        match write_atomic(&backup, rewritten.as_bytes()) {
5494            Ok(()) => tracing::info!(
5495                "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5496                backup
5497            ),
5498            Err(e) => tracing::error!(
5499                "Failed to write {:?} after stripping legacy connect data: {}",
5500                backup,
5501                e
5502            ),
5503        }
5504    }
5505}
5506
5507/// Quarantine an unparsable `connect.json` to a single `connect.json.bak`
5508/// generation (best-effort) so the bad content survives for inspection
5509/// instead of being silently discarded. Unlike config.json's timestamped,
5510/// N-generation quarantine, connect.json only needs one slot — it's a much
5511/// smaller, less complex document and this is a fail-SAFE (empty/inert
5512/// bridge), not a fail-recover, posture. #455.
5513///
5514/// MOVES the corrupt file rather than copying it (#457): a copy would leave
5515/// the same corrupt `connect.json` sitting in the data dir right next to its
5516/// own quarantine copy, which reads as confusing/ambiguous mid-incident
5517/// (which one is live?). `rename` is used first (atomic, no partial-copy
5518/// window); if that fails — e.g. `connect.json.bak` and the data dir are on
5519/// different filesystems — fall back to copy-then-remove so the corrupt
5520/// original still doesn't linger.
5521fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5522    let backup = connect_path.with_extension("json.bak");
5523    match std::fs::rename(connect_path, &backup) {
5524        Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5525        Err(e) => {
5526            tracing::warn!(
5527                "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5528                backup,
5529                e
5530            );
5531            if let Err(e) = std::fs::copy(connect_path, &backup) {
5532                tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5533                return;
5534            }
5535            if let Err(e) = std::fs::remove_file(connect_path) {
5536                tracing::error!(
5537                    "Quarantined corrupt connect.json to {:?} but failed to remove the \
5538                     original {:?}: {}",
5539                    backup,
5540                    connect_path,
5541                    e
5542                );
5543            }
5544        }
5545    }
5546}
5547
5548/// How many `config.json.corrupted.*` quarantine files to keep. Each corrupt load
5549/// drops one; without a cap they accumulate unbounded. Newest `N` are retained.
5550const QUARANTINE_KEEP: usize = 5;
5551
5552/// Copy a corrupt config file aside to `config.json.corrupted.<nanos>` so the
5553/// user's (unparseable) configuration is preserved for inspection/recovery
5554/// instead of being silently discarded and then overwritten by defaults. #37.
5555///
5556/// Returns the quarantine path on success, so the caller can attach it to a
5557/// [`ConfigRecoveryStatus`] (#153); `None` if even the copy failed (the
5558/// corrupt original is still left in place at `config_path` regardless, since
5559/// this only ever copies, never moves/deletes).
5560fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5561    let nanos = std::time::SystemTime::now()
5562        .duration_since(std::time::UNIX_EPOCH)
5563        .map(|d| d.as_nanos())
5564        .unwrap_or(0);
5565    // Two corrupt loads in the same nanosecond would land on the same name and the
5566    // second `copy` would silently overwrite the first. Append a counter on
5567    // collision so each quarantine is preserved distinctly. #135.
5568    let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5569    let mut dedup = 1u32;
5570    while quarantine.exists() {
5571        quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5572        dedup += 1;
5573    }
5574    let result = match std::fs::copy(config_path, &quarantine) {
5575        Ok(_) => {
5576            tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5577            Some(quarantine)
5578        }
5579        Err(e) => {
5580            tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5581            None
5582        }
5583    };
5584    prune_quarantine_files(config_path, QUARANTINE_KEEP);
5585    result
5586}
5587
5588/// Keep only the newest `keep` `config.json.corrupted.*` files next to
5589/// `config_path`, deleting older ones so quarantines don't grow unbounded. #135.
5590fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5591    let Some(dir) = config_path.parent() else {
5592        return;
5593    };
5594    let prefix = "config.json.corrupted.";
5595    let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5596        Ok(entries) => entries
5597            .filter_map(|e| e.ok())
5598            .map(|e| e.path())
5599            .filter(|p| {
5600                p.file_name()
5601                    .and_then(|n| n.to_str())
5602                    .is_some_and(|n| n.starts_with(prefix))
5603            })
5604            .collect(),
5605        Err(_) => return,
5606    };
5607    if quarantines.len() <= keep {
5608        return;
5609    }
5610    // Oldest first (by mtime; missing mtime sorts oldest so it's pruned first).
5611    quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5612    let remove = quarantines.len() - keep;
5613    for stale in quarantines.into_iter().take(remove) {
5614        if let Err(e) = std::fs::remove_file(&stale) {
5615            tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5616        }
5617    }
5618}
5619
5620/// Number of `config.json.bak[.N]` generations to retain (`.bak` + `N-1` numbered).
5621/// More generations = more recovery points if a fresher backup is itself bad. #135.
5622const BAK_GENERATIONS: usize = 3;
5623
5624/// The on-disk path of backup generation `gen` (0 == `config.json.bak`).
5625fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5626    if gen == 0 {
5627        config_path.with_extension("json.bak")
5628    } else {
5629        config_path.with_extension(format!("json.bak.{gen}"))
5630    }
5631}
5632
5633/// Shift the backup generations down before a fresh `.bak` is written:
5634/// `.bak.(N-2) -> .bak.(N-1)`, …, `.bak -> .bak.1`. The oldest is overwritten by
5635/// the shift; the caller then writes the new `.bak`. Walks the highest (oldest)
5636/// destination slot first so no rename clobbers a slot a later move still needs to
5637/// read. Best-effort. #135.
5638fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5639    for gen in (1..generations).rev() {
5640        let from = backup_path_for(config_path, gen - 1);
5641        let to = backup_path_for(config_path, gen);
5642        if from.exists() {
5643            if let Err(e) = std::fs::rename(&from, &to) {
5644                tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5645            }
5646        }
5647    }
5648}
5649
5650pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5651    crate::config_store::AtomicFileStore::new(path)
5652        .write_bytes_without_backup(content)
5653        .map_err(|error| match error {
5654            crate::config_store::ConfigStoreError::Io(error) => error,
5655            other => std::io::Error::other(other),
5656        })
5657}
5658
5659#[cfg(test)]
5660mod tests {
5661    use super::*;
5662    use std::ffi::OsString;
5663    use std::path::PathBuf;
5664    use std::sync::Mutex;
5665    use std::time::{SystemTime, UNIX_EPOCH};
5666
5667    #[test]
5668    fn tools_config_preserves_unknown_keys_across_roundtrip() {
5669        let input = serde_json::json!({
5670            "disabled": ["bash"],
5671            "plugin_runtime": {
5672                "timeout_ms": 5_000,
5673                "sandbox": true
5674            },
5675            "future_flag": "enabled"
5676        });
5677
5678        let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5679        assert_eq!(config.disabled, vec!["bash"]);
5680        assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5681        assert_eq!(config.extra["future_flag"], "enabled");
5682        assert_eq!(serde_json::to_value(config).unwrap(), input);
5683    }
5684
5685    #[test]
5686    fn tools_config_keeps_section_when_only_unknown_keys_present() {
5687        let input = serde_json::json!({
5688            "tool_extension": {
5689                "mode": "strict",
5690                "options": ["one", "two"]
5691            }
5692        });
5693        let config: Config = serde_json::from_value(serde_json::json!({
5694            "tools": input.clone()
5695        }))
5696        .unwrap();
5697
5698        assert!(config.tools.disabled.is_empty());
5699        assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5700        assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5701
5702        let temp_home = TempHome::new();
5703        config.save_to_dir(temp_home.path.clone()).unwrap();
5704        let persisted: Value =
5705            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5706                .unwrap();
5707        assert_eq!(persisted["tools"], input);
5708    }
5709
5710    #[test]
5711    fn skills_config_preserves_unknown_keys_across_roundtrip() {
5712        let input = serde_json::json!({
5713            "external_catalog": {
5714                "path": "/opt/bamboo/skills",
5715                "refresh": false
5716            },
5717            "schema_version": 2
5718        });
5719
5720        let config: Config = serde_json::from_value(serde_json::json!({
5721            "skills": input.clone()
5722        }))
5723        .unwrap();
5724        assert!(config.skills.disabled.is_empty());
5725        assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5726        assert_eq!(config.skills.extra["schema_version"], 2);
5727        assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5728
5729        let temp_home = TempHome::new();
5730        config.save_to_dir(temp_home.path.clone()).unwrap();
5731        let persisted: Value =
5732            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5733                .unwrap();
5734        assert_eq!(persisted["skills"], input);
5735    }
5736
5737    #[test]
5738    fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5739        let config: Config = serde_json::from_value(serde_json::json!({
5740            "hooks": {
5741                "image_fallback": {"enabled": true, "mode": "placeholder"}
5742            },
5743            "lifecycle_hooks": {
5744                "enabled": true,
5745                "PreToolUse": [{
5746                    "matcher": "bash|write_file",
5747                    "hooks": [{"type": "command", "command": "guard.sh"}]
5748                }],
5749                "SessionStart": [{
5750                    "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5751                }]
5752            }
5753        }))
5754        .expect("lifecycle hook config should deserialize");
5755
5756        assert!(config.lifecycle_hooks.enabled);
5757        assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5758        assert!(
5759            config.lifecycle_hooks.pre_tool_use[0].enabled,
5760            "legacy groups without an enabled flag remain active"
5761        );
5762        assert_eq!(
5763            config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5764            DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5765        );
5766        assert_eq!(
5767            config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5768            25
5769        );
5770
5771        let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5772        assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5773        assert_eq!(
5774            json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5775            "bash|write_file"
5776        );
5777        assert_eq!(
5778            json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5779            "command"
5780        );
5781        assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5782            .get("enabled")
5783            .is_none());
5784        assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5785            .get("timeout_ms")
5786            .is_none());
5787        assert!(json.get("hooks").is_some());
5788    }
5789
5790    #[test]
5791    fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5792        let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5793            "type": "script",
5794            "path": ".bamboo/hooks/check.js"
5795        }))
5796        .expect("script lifecycle hook should deserialize");
5797
5798        assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5799        assert!(matches!(
5800            handler,
5801            LifecycleHookHandler::Script {
5802                runner: LifecycleScriptRunner::Auto,
5803                ..
5804            }
5805        ));
5806        let json = serde_json::to_value(handler).expect("script hook should serialize");
5807        assert_eq!(json["type"], "script");
5808        assert!(json.get("timeout_ms").is_none());
5809        assert!(json.get("runner").is_none());
5810    }
5811
5812    #[test]
5813    fn script_runner_support_is_extension_aware() {
5814        assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5815        assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5816        assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5817        assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5818        assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5819        assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5820        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5821        assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5822        assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5823        assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5824    }
5825
5826    #[test]
5827    fn script_runner_names_round_trip_through_config_json() {
5828        for (runner, name) in [
5829            (LifecycleScriptRunner::Auto, "auto"),
5830            (LifecycleScriptRunner::Node, "node"),
5831            (LifecycleScriptRunner::Bun, "bun"),
5832            (LifecycleScriptRunner::Python, "python"),
5833            (LifecycleScriptRunner::Bash, "bash"),
5834            (LifecycleScriptRunner::PowerShell, "powershell"),
5835            (LifecycleScriptRunner::Cmd, "cmd"),
5836        ] {
5837            let json = serde_json::to_value(runner).unwrap();
5838            assert_eq!(json, name);
5839            assert_eq!(
5840                serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
5841                runner
5842            );
5843        }
5844    }
5845
5846    #[test]
5847    fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
5848        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5849        assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
5850        let json = serde_json::to_value(&config).unwrap();
5851        assert!(json.get("lifecycle_hooks").is_none());
5852    }
5853
5854    #[test]
5855    fn stream_timeout_defaults_are_safe_and_back_compatible() {
5856        let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
5857            .expect("legacy config without stream_timeout should load");
5858        let values = ConfigValues::from(root);
5859
5860        assert_eq!(
5861            values.stream_timeout,
5862            StreamTimeoutConfig {
5863                transport_idle_timeout_secs: 120,
5864                first_semantic_timeout_secs: 600,
5865                semantic_idle_timeout_secs: 600,
5866            }
5867        );
5868        values
5869            .stream_timeout
5870            .validate()
5871            .expect("defaults are valid");
5872    }
5873
5874    #[test]
5875    fn stream_timeout_round_trips_through_persistence_dto() {
5876        let values = ConfigValues {
5877            stream_timeout: StreamTimeoutConfig {
5878                transport_idle_timeout_secs: 45,
5879                first_semantic_timeout_secs: 900,
5880                semantic_idle_timeout_secs: 300,
5881            },
5882            ..ConfigValues::default()
5883        };
5884
5885        let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
5886        assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
5887        assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
5888        assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
5889
5890        let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
5891        assert_eq!(
5892            ConfigValues::from(root).stream_timeout,
5893            StreamTimeoutConfig {
5894                transport_idle_timeout_secs: 45,
5895                first_semantic_timeout_secs: 900,
5896                semantic_idle_timeout_secs: 300,
5897            }
5898        );
5899    }
5900
5901    #[test]
5902    fn context_management_defaults_to_summary_and_is_omitted() {
5903        let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
5904        assert_eq!(
5905            config.context_management.strategy,
5906            ContextManagementStrategy::Summary
5907        );
5908        assert_eq!(
5909            config
5910                .context_management
5911                .retrieval_window
5912                .min_recent_user_turns,
5913            3
5914        );
5915        assert_eq!(
5916            config.context_management.retrieval_target_usage_percent(),
5917            60
5918        );
5919        let json = serde_json::to_value(&config).unwrap();
5920        assert!(json.get("context_management").is_none());
5921    }
5922
5923    #[test]
5924    fn retrieval_window_context_management_round_trips() {
5925        let json = serde_json::json!({
5926            "context_management": {
5927                "strategy": "retrieval_window",
5928                "retrieval_window": {
5929                    "min_recent_user_turns": 4,
5930                    "trigger_usage_ratio": 0.82,
5931                    "target_usage_ratio": 0.61,
5932                    "history_tool_required": true,
5933                    "fallback_strategy": "summary"
5934                }
5935            }
5936        });
5937        let root: ConfigRoot = serde_json::from_value(json).unwrap();
5938        let values = ConfigValues::from(root);
5939        assert_eq!(
5940            values.context_management.strategy,
5941            ContextManagementStrategy::RetrievalWindow
5942        );
5943        assert_eq!(
5944            values.context_management.retrieval_window.fallback_strategy,
5945            ContextManagementFallbackStrategy::Summary
5946        );
5947
5948        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5949        assert_eq!(
5950            persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
5951            4
5952        );
5953        assert_eq!(
5954            persisted["context_management"]["strategy"],
5955            "retrieval_window"
5956        );
5957    }
5958
5959    #[test]
5960    fn message_summary_context_management_round_trips_without_changing_defaults() {
5961        let root: ConfigRoot = serde_json::from_value(serde_json::json!({
5962            "context_management": {
5963                "strategy": "message_summary"
5964            }
5965        }))
5966        .unwrap();
5967        let values = ConfigValues::from(root);
5968        assert_eq!(
5969            values.context_management.strategy,
5970            ContextManagementStrategy::MessageSummary
5971        );
5972        assert_eq!(
5973            values.context_management.retrieval_window,
5974            RetrievalWindowContextConfig::default()
5975        );
5976
5977        let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
5978        assert_eq!(
5979            persisted["context_management"]["strategy"],
5980            "message_summary"
5981        );
5982        let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
5983        assert_eq!(
5984            ConfigValues::from(reloaded).context_management.strategy,
5985            ContextManagementStrategy::MessageSummary
5986        );
5987        assert_eq!(
5988            ContextManagementConfig::default().strategy,
5989            ContextManagementStrategy::Summary
5990        );
5991    }
5992
5993    #[test]
5994    fn retrieval_window_context_management_rejects_unsafe_policy() {
5995        for invalid in [
5996            serde_json::json!({
5997                "strategy": "retrieval_window",
5998                "retrieval_window": {"min_recent_user_turns": 0}
5999            }),
6000            serde_json::json!({
6001                "strategy": "retrieval_window",
6002                "retrieval_window": {
6003                    "target_usage_ratio": 0.8,
6004                    "trigger_usage_ratio": 0.8
6005                }
6006            }),
6007            serde_json::json!({
6008                "strategy": "retrieval_window",
6009                "retrieval_window": {
6010                    "target_usage_ratio": 0.005,
6011                    "trigger_usage_ratio": 0.006
6012                }
6013            }),
6014            serde_json::json!({
6015                "strategy": "retrieval_window",
6016                "retrieval_window": {"history_tool_required": false}
6017            }),
6018        ] {
6019            serde_json::from_value::<ContextManagementConfig>(invalid)
6020                .expect_err("unsafe retrieval-window policy must fail closed");
6021        }
6022    }
6023
6024    #[test]
6025    fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6026        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6027            "provider_type": "openai",
6028            "enabled": true
6029        }))
6030        .unwrap();
6031        let mut config = Config::default();
6032        config.values.provider = "gemini".to_string();
6033        config
6034            .provider_instances
6035            .insert("work".to_string(), instance);
6036        config.default_provider_instance = Some("work".to_string());
6037        config.providers_mut().openai = Some(OpenAIConfig::default());
6038
6039        let json = serde_json::to_value(&config).unwrap();
6040        assert_eq!(json["provider"], "gemini");
6041        assert!(json["providers"]["openai"].is_object());
6042        assert_eq!(json["default_provider_instance"], "work");
6043
6044        let round_trip: Config = serde_json::from_value(json).unwrap();
6045        assert_eq!(round_trip.provider, "gemini");
6046        assert_eq!(
6047            round_trip.default_provider_instance.as_deref(),
6048            Some("work")
6049        );
6050        assert!(round_trip.provider_instances.contains_key("work"));
6051        assert!(round_trip.providers().openai.is_some());
6052    }
6053
6054    #[test]
6055    fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6056        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6057        let dir = tempfile::tempdir().unwrap();
6058        let mut config = Config::default();
6059        config.values.provider = "anthropic".to_string();
6060        config.provider_instances.insert(
6061            "work".to_string(),
6062            serde_json::from_value(serde_json::json!({
6063                "provider_type": "openai",
6064                "model": "gpt-instance",
6065                "enabled": true,
6066                "future_instance_metadata": "preserved"
6067            }))
6068            .unwrap(),
6069        );
6070        config.default_provider_instance = Some("work".to_string());
6071        config.features.provider_model_ref = true;
6072        config.providers_mut().openai = Some(OpenAIConfig::default());
6073        config.providers_mut().anthropic = Some(AnthropicConfig::default());
6074        config
6075            .providers_mut()
6076            .extra
6077            .insert("provider".to_string(), serde_json::json!("anthropic"));
6078        config.providers_mut().extra.insert(
6079            "future_provider".to_string(),
6080            serde_json::json!({"kept": true}),
6081        );
6082
6083        let (root_bytes, provider_bytes) =
6084            config.prepare_provider_transaction_documents(&[]).unwrap();
6085        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6086        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6087        assert!(root.get("provider").is_none());
6088        assert!(root.get("providers").is_none());
6089        assert_eq!(root["default_provider_instance"], "work");
6090        assert_eq!(
6091            root["provider_instances"]["work"]["future_instance_metadata"],
6092            "preserved"
6093        );
6094        assert!(providers.get("provider").is_none());
6095        for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6096            assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6097        }
6098        assert_eq!(providers["future_provider"]["kept"], true);
6099
6100        config.save_to_dir(dir.path().to_path_buf()).unwrap();
6101        let saved_root: Value =
6102            serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6103                .unwrap();
6104        let saved_providers: Value =
6105            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6106                .unwrap();
6107        assert!(saved_root.get("provider").is_none());
6108        assert!(saved_root.get("providers").is_none());
6109        assert_eq!(saved_root["default_provider_instance"], "work");
6110        assert!(saved_providers.get("openai").is_none());
6111        assert!(saved_providers.get("anthropic").is_none());
6112        assert!(saved_providers.get("provider").is_none());
6113        assert_eq!(saved_providers["future_provider"]["kept"], true);
6114
6115        config.providers_mut().gemini = Some(GeminiConfig::default());
6116        config.save_providers_to_dir(dir.path()).unwrap();
6117        let provider_only: Value =
6118            serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6119                .unwrap();
6120        assert!(provider_only.get("gemini").is_none());
6121        assert!(provider_only.get("provider").is_none());
6122        assert_eq!(provider_only["future_provider"]["kept"], true);
6123    }
6124
6125    #[test]
6126    fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6127        let mut config = Config::default();
6128        config.values.provider = "openai".to_string();
6129        config.providers_mut().openai = Some(OpenAIConfig {
6130            model: Some("gpt-legacy".to_string()),
6131            ..OpenAIConfig::default()
6132        });
6133        config.provider_instances.insert(
6134            "work".to_string(),
6135            serde_json::from_value(serde_json::json!({
6136                "provider_type": "copilot",
6137                "enabled": true
6138            }))
6139            .unwrap(),
6140        );
6141        config.default_provider_instance = Some("openai".to_string());
6142
6143        let (root_bytes, provider_bytes) =
6144            config.prepare_provider_transaction_documents(&[]).unwrap();
6145        let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6146        let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6147        assert_eq!(root["provider"], "openai");
6148        assert_eq!(root["default_provider_instance"], "openai");
6149        assert!(root["provider_instances"]["work"].is_object());
6150        assert_eq!(providers["openai"]["model"], "gpt-legacy");
6151    }
6152
6153    #[test]
6154    fn persistence_keeps_legacy_provider_without_instance_default() {
6155        let values = ConfigValues {
6156            provider: "gemini".to_string(),
6157            ..ConfigValues::default()
6158        };
6159
6160        let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6161        assert_eq!(json["provider"], "gemini");
6162    }
6163
6164    #[test]
6165    fn stream_timeout_rejects_zero_and_unbounded_values() {
6166        for (field, invalid) in [
6167            ("transport_idle_timeout_secs", 0),
6168            ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6169            ("semantic_idle_timeout_secs", 0),
6170        ] {
6171            let mut timeout = serde_json::json!({});
6172            timeout[field] = serde_json::json!(invalid);
6173            let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6174                .expect_err("invalid timeout must be rejected");
6175            assert!(error.to_string().contains("stream timeout must be between"));
6176        }
6177    }
6178
6179    struct EnvVarGuard {
6180        key: &'static str,
6181        previous: Option<OsString>,
6182    }
6183
6184    impl EnvVarGuard {
6185        fn set(key: &'static str, value: &str) -> Self {
6186            let previous = std::env::var_os(key);
6187            std::env::set_var(key, value);
6188            Self { key, previous }
6189        }
6190
6191        fn unset(key: &'static str) -> Self {
6192            let previous = std::env::var_os(key);
6193            std::env::remove_var(key);
6194            Self { key, previous }
6195        }
6196    }
6197
6198    impl Drop for EnvVarGuard {
6199        fn drop(&mut self) {
6200            match &self.previous {
6201                Some(value) => std::env::set_var(self.key, value),
6202                None => std::env::remove_var(self.key),
6203            }
6204        }
6205    }
6206
6207    #[test]
6208    fn run_budget_config_merge_is_tighten_only_per_field() {
6209        let config_default = RunBudgetConfig {
6210            max_total_tokens: Some(100_000),
6211            max_tool_calls: Some(500),
6212            max_subagents: Some(10),
6213            max_rounds: Some(200),
6214        };
6215
6216        // No override at all: config default passes through unchanged.
6217        assert_eq!(
6218            config_default.merged_with_override(None),
6219            config_default,
6220            "no override falls back to the config default entirely"
6221        );
6222
6223        // Override TIGHTENS exactly one field; the others keep the config
6224        // default (per-field, not all-or-nothing).
6225        let tighten_one = RunBudgetConfig {
6226            max_total_tokens: Some(5_000),
6227            max_tool_calls: None,
6228            max_subagents: None,
6229            max_rounds: None,
6230        };
6231        let merged = config_default.merged_with_override(Some(&tighten_one));
6232        assert_eq!(merged.max_total_tokens, Some(5_000));
6233        assert_eq!(merged.max_tool_calls, Some(500));
6234        assert_eq!(merged.max_subagents, Some(10));
6235        assert_eq!(merged.max_rounds, Some(200));
6236
6237        // A LOOSER override is clamped to the config default: a client can
6238        // never raise the operator's ceiling (PR #539 review, finding #3).
6239        let loosen_attempt = RunBudgetConfig {
6240            max_total_tokens: Some(999_999_999),
6241            max_tool_calls: Some(10_000),
6242            max_subagents: Some(1_000),
6243            max_rounds: Some(100_000),
6244        };
6245        assert_eq!(
6246            config_default.merged_with_override(Some(&loosen_attempt)),
6247            config_default,
6248            "looser per-request values must be clamped to the config ceiling"
6249        );
6250
6251        // Nor can it REMOVE a configured ceiling by omitting the field: an
6252        // absent override field keeps the config default, it does not mean
6253        // unlimited.
6254        let empty_override = RunBudgetConfig::default();
6255        assert_eq!(
6256            config_default.merged_with_override(Some(&empty_override)),
6257            config_default,
6258            "an all-absent override body keeps every configured ceiling"
6259        );
6260
6261        // An unlimited config default CAN be tightened by the request (the
6262        // request is the only ceiling then), and stays unlimited on fields the
6263        // request does not set.
6264        let unlimited_default = RunBudgetConfig::default();
6265        let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6266        assert_eq!(merged.max_total_tokens, Some(5_000));
6267        assert_eq!(merged.max_tool_calls, None);
6268        assert_eq!(merged.max_subagents, None);
6269        assert_eq!(merged.max_rounds, None);
6270
6271        // The round cap tightens the same way: a request can cap rounds below
6272        // an unlimited config default but never raise a configured ceiling.
6273        let tighten_rounds = RunBudgetConfig {
6274            max_total_tokens: None,
6275            max_tool_calls: None,
6276            max_subagents: None,
6277            max_rounds: Some(20),
6278        };
6279        let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6280        assert_eq!(merged.max_rounds, Some(20));
6281        let merged = config_default.merged_with_override(Some(&tighten_rounds));
6282        assert_eq!(
6283            merged.max_rounds,
6284            Some(20),
6285            "a stricter per-request round cap wins over the config default"
6286        );
6287    }
6288
6289    #[test]
6290    fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6291        assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6292        assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6293        assert_eq!(RunBudgetConfig::default().max_subagents, None);
6294        assert_eq!(RunBudgetConfig::default().max_rounds, None);
6295
6296        let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6297        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6298        assert_eq!(cfg.max_total_tokens, Some(250_000));
6299        assert_eq!(
6300            cfg.max_tool_calls, None,
6301            "absent field defaults to unlimited"
6302        );
6303        assert_eq!(
6304            cfg.max_rounds, None,
6305            "absent round cap defaults to unlimited"
6306        );
6307
6308        assert_eq!(cfg.max_subagents, Some(3));
6309
6310        // The round cap round-trips like the other fields.
6311        let json = r#"{ "max_rounds": 250 }"#;
6312        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6313        assert_eq!(cfg.max_rounds, Some(250));
6314        let round_tripped: RunBudgetConfig =
6315            serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6316        assert_eq!(round_tripped.max_rounds, Some(250));
6317        assert_eq!(round_tripped.max_total_tokens, None);
6318
6319        // Absent fields are omitted on serialize (skip_serializing_if), so an
6320        // all-default config round-trips to `{}` rather than four explicit
6321        // nulls.
6322        let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6323        assert_eq!(empty, "{}");
6324    }
6325
6326    #[test]
6327    fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6328        let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6329        assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6330        assert!(!serde_json::to_string(&old)
6331            .unwrap()
6332            .contains("max_spawn_depth"));
6333        for depth in [0, 1, 8] {
6334            let config: SubagentsConfig =
6335                serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6336            assert_eq!(config.effective_max_spawn_depth(), depth);
6337            assert_eq!(
6338                serde_json::to_value(config).unwrap()["max_spawn_depth"],
6339                depth
6340            );
6341        }
6342        for invalid in [
6343            serde_json::json!(-1),
6344            serde_json::json!(1.5),
6345            serde_json::json!("4"),
6346        ] {
6347            assert!(serde_json::from_value::<SubagentsConfig>(
6348                serde_json::json!({"max_spawn_depth": invalid}),
6349            )
6350            .is_err());
6351        }
6352    }
6353
6354    #[test]
6355    fn subagents_config_without_remote_placements_deserializes_empty() {
6356        // An OLD config (predating P1.5) has no `remote_placements` key — it must
6357        // still deserialize, with an empty placement list (default = local path).
6358        let json = r#"{ "max_concurrent": 4 }"#;
6359        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6360        assert_eq!(cfg.max_concurrent, Some(4));
6361        assert!(cfg.remote_placements.is_empty());
6362        // And an empty placement list is omitted on re-serialize (skip_if empty).
6363        let back = serde_json::to_string(&cfg).unwrap();
6364        assert!(
6365            !back.contains("remote_placements"),
6366            "empty vec is skipped: {back}"
6367        );
6368    }
6369
6370    #[test]
6371    fn remote_actor_placement_round_trips() {
6372        let json = r#"{
6373            "remote_placements": [
6374                {
6375                    "role": "explorer",
6376                    "endpoint": "wss://gpu-host:8443",
6377                    "token_env": "WORKER_TOKEN",
6378                    "ca_cert_file": "/etc/bamboo/worker.pem"
6379                },
6380                { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6381            ]
6382        }"#;
6383        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6384        assert_eq!(cfg.remote_placements.len(), 2);
6385        let p0 = &cfg.remote_placements[0];
6386        assert_eq!(p0.role, "explorer");
6387        assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6388        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6389        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6390        // Optional fields default to None and are skipped on serialize.
6391        let p1 = &cfg.remote_placements[1];
6392        assert_eq!(p1.role, "writer");
6393        assert!(p1.token_env.is_none());
6394        assert!(p1.ca_cert_file.is_none());
6395
6396        let back = serde_json::to_string(&cfg).unwrap();
6397        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6398        assert_eq!(cfg, reparsed, "round-trip is stable");
6399        assert!(!back.contains("\"token_env\":null"));
6400        assert!(!back.contains("\"ca_cert_file\":null"));
6401    }
6402
6403    #[test]
6404    fn subagents_config_without_schedulable_placements_deserializes_empty() {
6405        // An OLD config (predating P2b) has no `schedulable_placements` key — it
6406        // must still deserialize, with an empty list (default = local path).
6407        let json = r#"{ "max_concurrent": 4 }"#;
6408        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6409        assert!(cfg.schedulable_placements.is_empty());
6410        // An empty list is omitted on re-serialize (skip_if empty).
6411        let back = serde_json::to_string(&cfg).unwrap();
6412        assert!(
6413            !back.contains("schedulable_placements"),
6414            "empty vec is skipped: {back}"
6415        );
6416    }
6417
6418    #[test]
6419    fn schedulable_placement_round_trips() {
6420        let json = r#"{
6421            "schedulable_placements": [
6422                {
6423                    "role": "explorer",
6424                    "pool": "gpu-pool",
6425                    "registry_url": "https://control-plane:9562",
6426                    "token_env": "WORKER_TOKEN",
6427                    "ca_cert_file": "/etc/bamboo/worker.pem"
6428                },
6429                { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6430            ]
6431        }"#;
6432        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6433        assert_eq!(cfg.schedulable_placements.len(), 2);
6434        let p0 = &cfg.schedulable_placements[0];
6435        assert_eq!(p0.role, "explorer");
6436        assert_eq!(p0.pool, "gpu-pool");
6437        assert_eq!(p0.registry_url, "https://control-plane:9562");
6438        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6439        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6440        // Optional fields default to None and are skipped on serialize.
6441        let p1 = &cfg.schedulable_placements[1];
6442        assert_eq!(p1.role, "writer");
6443        assert_eq!(p1.pool, "cpu-pool");
6444        assert!(p1.token_env.is_none());
6445        assert!(p1.ca_cert_file.is_none());
6446
6447        let back = serde_json::to_string(&cfg).unwrap();
6448        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6449        assert_eq!(cfg, reparsed, "round-trip is stable");
6450        assert!(!back.contains("\"token_env\":null"));
6451        assert!(!back.contains("\"ca_cert_file\":null"));
6452    }
6453
6454    #[test]
6455    fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6456        // An OLD config (predating #54's wiring) has no `mcp_role_allowlist`
6457        // key — it must still deserialize, with an empty list (default =
6458        // every role unrestricted, identical to pre-#54 behavior).
6459        let json = r#"{ "max_concurrent": 4 }"#;
6460        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6461        assert!(cfg.mcp_role_allowlist.is_empty());
6462        // An empty list is omitted on re-serialize (skip_if empty).
6463        let back = serde_json::to_string(&cfg).unwrap();
6464        assert!(
6465            !back.contains("mcp_role_allowlist"),
6466            "empty vec is skipped: {back}"
6467        );
6468    }
6469
6470    #[test]
6471    fn mcp_role_allowlist_entry_round_trips() {
6472        let json = r#"{
6473            "mcp_role_allowlist": [
6474                { "role": "researcher", "tools": ["fetch_url"] },
6475                { "role": "sandboxed", "tools": [] }
6476            ]
6477        }"#;
6478        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6479        assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6480        assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6481        assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6482        // An empty `tools` list is an explicit lockout, distinct from the role
6483        // being absent — it must round-trip as an empty (not omitted) list.
6484        assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6485        assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6486
6487        let back = serde_json::to_string(&cfg).unwrap();
6488        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6489        assert_eq!(cfg, reparsed, "round-trip is stable");
6490    }
6491
6492    #[test]
6493    fn server_config_without_tls_field_deserializes_back_compat() {
6494        // An old config.json `server` section with no `tls` key must still
6495        // deserialize, leaving `tls` as None (zero behavior change on upgrade).
6496        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6497            "port": 9562,
6498            "bind": "127.0.0.1"
6499        }))
6500        .expect("legacy server config without tls should deserialize");
6501
6502        assert_eq!(server.tls, None);
6503        assert_eq!(server.port, 9562);
6504        assert_eq!(server.bind, "127.0.0.1");
6505    }
6506
6507    #[test]
6508    fn server_config_omits_tls_when_none() {
6509        // `skip_serializing_if = "Option::is_none"` keeps the on-disk shape
6510        // identical to before for the common (no-TLS) case.
6511        let server = ServerConfig::default();
6512        let value = serde_json::to_value(&server).expect("server config should serialize");
6513        let obj = value
6514            .as_object()
6515            .expect("server config serializes to object");
6516        assert!(
6517            !obj.contains_key("tls"),
6518            "tls must be omitted when None, got: {value}"
6519        );
6520    }
6521
6522    #[test]
6523    fn server_config_with_tls_roundtrips() {
6524        let server: ServerConfig = serde_json::from_value(serde_json::json!({
6525            "port": 9562,
6526            "bind": "0.0.0.0",
6527            "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6528        }))
6529        .expect("server config with tls should deserialize");
6530
6531        let tls = server.tls.clone().expect("tls should be Some");
6532        assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6533        assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6534
6535        // Round-trips: tls survives a serialize → deserialize cycle.
6536        let value = serde_json::to_value(&server).expect("serialize");
6537        assert!(value.as_object().unwrap().contains_key("tls"));
6538        let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6539        assert_eq!(back.tls, server.tls);
6540    }
6541
6542    #[test]
6543    fn access_control_without_devices_field_deserializes_back_compat() {
6544        // An old config.json `access_control` with no `devices` key must still
6545        // deserialize, leaving `devices` empty (root-password-only mode).
6546        let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6547            "password_enabled": true,
6548            "password_hash": "deadbeef",
6549            "password_salt": "01020304",
6550        }))
6551        .expect("legacy access_control without devices should deserialize");
6552
6553        assert!(access.devices.is_empty());
6554        assert!(access.password_enabled);
6555    }
6556
6557    #[test]
6558    fn access_control_omits_devices_when_empty() {
6559        // `skip_serializing_if = "Vec::is_empty"` keeps the on-disk shape
6560        // identical for instances that never paired a device.
6561        let access = AccessControlConfig {
6562            password_enabled: true,
6563            repair_required: false,
6564            password_hash: Some("deadbeef".to_string()),
6565            password_salt: Some("01020304".to_string()),
6566            password_credential_ref: None,
6567            password_configured: false,
6568            updated_at: None,
6569            devices: Vec::new(),
6570        };
6571        let value = serde_json::to_value(&access).expect("serialize");
6572        let obj = value.as_object().expect("object");
6573        assert!(
6574            !obj.contains_key("devices"),
6575            "devices must be omitted when empty, got: {value}"
6576        );
6577    }
6578
6579    #[test]
6580    fn access_control_with_devices_roundtrips() {
6581        let device = DeviceCredential {
6582            device_id: "bamboo_0123456789ab".to_string(),
6583            label: "iPhone 15".to_string(),
6584            token_hash: "abcd".to_string(),
6585            token_salt: "ef01".to_string(),
6586            token_credential_ref: None,
6587            token_configured: false,
6588            created_at: "2026-06-23T00:00:00Z".to_string(),
6589            last_used_at: None,
6590            revoked: false,
6591        };
6592        let access = AccessControlConfig {
6593            password_enabled: true,
6594            repair_required: false,
6595            password_hash: Some("deadbeef".to_string()),
6596            password_salt: Some("01020304".to_string()),
6597            password_credential_ref: None,
6598            password_configured: false,
6599            updated_at: None,
6600            devices: vec![device.clone()],
6601        };
6602        let value = serde_json::to_value(&access).expect("serialize");
6603        assert!(value.as_object().unwrap().contains_key("devices"));
6604        assert!(value["devices"][0].get("token_hash").is_none());
6605        assert!(value["devices"][0].get("token_salt").is_none());
6606        let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6607        assert_eq!(back.devices[0].device_id, device.device_id);
6608        assert!(back.devices[0].token_hash.is_empty());
6609        assert!(back.devices[0].token_salt.is_empty());
6610    }
6611
6612    #[test]
6613    fn reasoning_effort_for_key_resolves_instance_id() {
6614        // Multi-instance mode: the routing key is an instance id and the effort
6615        // lives under provider_instances[<id>] — previously this fell through to
6616        // None because the resolver only matched literal provider types.
6617        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6618            "provider_type": "copilot",
6619            "reasoning_effort": "high",
6620        }))
6621        .expect("instance config should deserialize");
6622
6623        let mut config = Config::create_default();
6624        config
6625            .provider_instances
6626            .insert("copilot-work".to_string(), instance);
6627
6628        assert_eq!(
6629            config.reasoning_effort_for_key("copilot-work"),
6630            Some(ReasoningEffort::High),
6631        );
6632    }
6633
6634    #[test]
6635    fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6636        // Legacy mode: the `bodhi` provider previously had no match arm.
6637        let mut config = Config::create_default();
6638        config.providers.bodhi = Some(
6639            serde_json::from_value(serde_json::json!({
6640                "reasoning_effort": "xhigh",
6641            }))
6642            .expect("bodhi config should deserialize"),
6643        );
6644
6645        assert_eq!(
6646            config.reasoning_effort_for_key("bodhi"),
6647            Some(ReasoningEffort::Xhigh),
6648        );
6649    }
6650
6651    #[test]
6652    fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6653        let mut config = Config::create_default();
6654        config.providers.openai = Some(
6655            serde_json::from_value(serde_json::json!({
6656                "api_key": "sk-test",
6657                "reasoning_effort": "low",
6658            }))
6659            .expect("openai config should deserialize"),
6660        );
6661
6662        assert_eq!(
6663            config.reasoning_effort_for_key("openai"),
6664            Some(ReasoningEffort::Low),
6665        );
6666    }
6667
6668    #[test]
6669    fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6670        let config = Config::create_default();
6671        assert_eq!(config.reasoning_effort_for_key("nope"), None);
6672        assert_eq!(config.reasoning_effort_for_key("   "), None);
6673    }
6674
6675    struct TempHome {
6676        path: PathBuf,
6677    }
6678
6679    impl TempHome {
6680        fn new() -> Self {
6681            // `pid + nanos` alone is NOT collision-free (issue #486): every
6682            // test in this binary shares the pid, and two tests started
6683            // concurrently by the multi-threaded harness can observe the
6684            // same `SystemTime` nanos tick. Two `TempHome`s colliding on one
6685            // path means they share a directory — and the first test's
6686            // `Drop` (`remove_dir_all`) then yanks the directory out from
6687            // under the other test's in-flight `save_to_dir`, whose
6688            // tmp-file+rename dance fails with ENOENT ("Failed to write
6689            // config file ... os error 2" — `save_rotates_backup_generations`'s
6690            // exact one-off CI failure mode). A per-process atomic counter
6691            // in the name makes each instance unique unconditionally.
6692            static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6693                std::sync::atomic::AtomicU64::new(0);
6694            let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6695            let nanos = SystemTime::now()
6696                .duration_since(UNIX_EPOCH)
6697                .expect("clock should be after unix epoch")
6698                .as_nanos();
6699            let path = std::env::temp_dir().join(format!(
6700                "chat-core-config-test-{}-{}-{}",
6701                std::process::id(),
6702                nanos,
6703                unique
6704            ));
6705            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6706            Self { path }
6707        }
6708
6709        fn set_config_json(&self, content: &str) {
6710            // Treat `path` as the Bamboo data dir and write `config.json` into it.
6711            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
6712            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
6713            std::fs::write(self.path.join("config.json"), content)
6714                .expect("failed to write config.json");
6715        }
6716    }
6717
6718    impl Drop for TempHome {
6719        fn drop(&mut self) {
6720            let _ = std::fs::remove_dir_all(&self.path);
6721        }
6722    }
6723
6724    // Delegate to the single crate-wide test lock so env-mutating tests across
6725    // `config`, `encryption`, and `paths` serialize against one another (they
6726    // all mutate the same process-global env / static caches).
6727    fn env_lock() -> &'static Mutex<()> {
6728        crate::test_support::env_cache_lock()
6729    }
6730
6731    /// Acquire the environment lock, recovering from poison if a previous test failed
6732    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
6733        env_lock().lock().unwrap_or_else(|poisoned| {
6734            // Lock was poisoned by a previous test failure - recover it
6735            poisoned.into_inner()
6736        })
6737    }
6738
6739    #[test]
6740    fn parse_bool_env_true_values() {
6741        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
6742            assert!(parse_bool_env(value), "value {value:?} should be true");
6743        }
6744    }
6745
6746    #[test]
6747    fn parse_bool_env_false_values() {
6748        for value in ["0", "false", "no", "off", "", "  "] {
6749            assert!(!parse_bool_env(value), "value {value:?} should be false");
6750        }
6751    }
6752
6753    #[test]
6754    fn config_new_ignores_http_proxy_env_vars() {
6755        let _lock = env_lock_acquire();
6756        let temp_home = TempHome::new();
6757        temp_home.set_config_json(
6758            r#"{
6759  "http_proxy": "",
6760  "https_proxy": ""
6761}"#,
6762        );
6763
6764        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
6765        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
6766
6767        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6768
6769        assert!(
6770            config.http_proxy.is_empty(),
6771            "config should ignore HTTP_PROXY env var"
6772        );
6773        assert!(
6774            config.https_proxy.is_empty(),
6775            "config should ignore HTTPS_PROXY env var"
6776        );
6777    }
6778
6779    #[test]
6780    fn config_new_loads_config_when_proxy_fields_omitted() {
6781        let _lock = env_lock_acquire();
6782        let temp_home = TempHome::new();
6783        temp_home.set_config_json(
6784            r#"{
6785  "provider": "openai",
6786  "providers": {
6787    "openai": {
6788      "api_key": "sk-test",
6789      "model": "gpt-4o"
6790    }
6791  }
6792}"#,
6793        );
6794
6795        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
6796        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
6797
6798        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6799
6800        assert_eq!(
6801            config
6802                .providers
6803                .openai
6804                .as_ref()
6805                .and_then(|c| c.model.as_deref()),
6806            Some("gpt-4o"),
6807            "config should load provider model from config file even when proxy fields are omitted"
6808        );
6809        assert!(config.http_proxy.is_empty());
6810        assert!(config.https_proxy.is_empty());
6811    }
6812
6813    #[test]
6814    fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
6815        let _lock = crate::test_support::env_cache_lock_acquire();
6816        let mut config = Config::default();
6817        config.env_vars.extend([
6818            EnvVarEntry {
6819                name: "SECRET_TOKEN".to_string(),
6820                value: "top-secret".to_string(),
6821                secret: true,
6822                value_encrypted: None,
6823                credential_ref: None,
6824                configured: true,
6825                description: Some("Service token".to_string()),
6826            },
6827            EnvVarEntry {
6828                name: "API_BASE".to_string(),
6829                value: "https://internal.example".to_string(),
6830                secret: false,
6831                value_encrypted: None,
6832                credential_ref: None,
6833                configured: true,
6834                description: Some("Internal API base".to_string()),
6835            },
6836        ]);
6837
6838        config.publish_env_vars();
6839
6840        let injected = Config::current_env_vars();
6841        assert_eq!(
6842            injected.get("SECRET_TOKEN").map(String::as_str),
6843            Some("top-secret")
6844        );
6845        assert_eq!(
6846            injected.get("API_BASE").map(String::as_str),
6847            Some("https://internal.example")
6848        );
6849
6850        let prompt_safe = Config::current_prompt_safe_env_vars();
6851        assert_eq!(prompt_safe.len(), 2);
6852        assert!(prompt_safe.iter().any(|entry| {
6853            entry.name == "SECRET_TOKEN"
6854                && entry.secret
6855                && entry.description.as_deref() == Some("Service token")
6856        }));
6857        assert!(prompt_safe.iter().any(|entry| {
6858            entry.name == "API_BASE"
6859                && !entry.secret
6860                && entry.description.as_deref() == Some("Internal API base")
6861        }));
6862        assert!(!prompt_safe
6863            .iter()
6864            .any(|entry| entry.name.contains("top-secret")));
6865        assert!(!prompt_safe.iter().any(|entry| {
6866            entry
6867                .description
6868                .as_deref()
6869                .is_some_and(|value| value.contains("https://internal.example"))
6870        }));
6871    }
6872
6873    #[test]
6874    fn from_data_dir_without_publish_does_not_clobber_global_cache() {
6875        let _lock = crate::test_support::env_cache_lock_acquire();
6876
6877        // Seed the global cache with a marker "owned" by the live config.
6878        let mut live = Config::default();
6879        live.env_vars.extend([EnvVarEntry {
6880            name: "BAMBOO_CACHE_OWNER_40".to_string(),
6881            value: "live".to_string(),
6882            secret: false,
6883            value_encrypted: None,
6884            credential_ref: None,
6885            configured: true,
6886            description: None,
6887        }]);
6888        live.publish_env_vars();
6889        assert_eq!(
6890            Config::current_env_vars()
6891                .get("BAMBOO_CACHE_OWNER_40")
6892                .map(String::as_str),
6893            Some("live")
6894        );
6895
6896        // A config.json on disk sets the SAME var to a different (stale) value.
6897        let temp = TempHome::new();
6898        temp.set_config_json(
6899            &serde_json::json!({
6900                "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
6901            })
6902            .to_string(),
6903        );
6904
6905        // Non-publishing load reads the disk value into the returned Config but
6906        // must NOT touch the global cache.
6907        let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6908        assert_eq!(
6909            loaded
6910                .env_vars
6911                .iter()
6912                .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
6913                .map(|e| e.value.as_str()),
6914            Some("stale-disk"),
6915            "the returned Config holds the disk value"
6916        );
6917        assert_eq!(
6918            Config::current_env_vars()
6919                .get("BAMBOO_CACHE_OWNER_40")
6920                .map(String::as_str),
6921            Some("live"),
6922            "but the global cache is UNTOUCHED — no clobber (#40)"
6923        );
6924
6925        // Contrast: the publishing variant DOES clobber the cache.
6926        let _ = Config::from_data_dir(Some(temp.path.clone()));
6927        assert_eq!(
6928            Config::current_env_vars()
6929                .get("BAMBOO_CACHE_OWNER_40")
6930                .map(String::as_str),
6931            Some("stale-disk"),
6932            "the publishing loader clobbers the cache (contrast)"
6933        );
6934    }
6935
6936    fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
6937        std::fs::read_dir(dir)
6938            .unwrap()
6939            .filter_map(|e| e.ok())
6940            .any(|e| {
6941                e.file_name()
6942                    .to_string_lossy()
6943                    .contains("config.json.corrupted.")
6944            })
6945    }
6946
6947    #[test]
6948    fn corrupt_config_recovered_from_backup_and_quarantined() {
6949        let temp = TempHome::new();
6950        // Last-known-good backup with a distinctive value.
6951        std::fs::write(
6952            temp.path.join("config.json.bak"),
6953            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
6954        )
6955        .unwrap();
6956        // Corrupt primary config.json.
6957        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
6958
6959        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6960        assert_eq!(
6961            config.http_proxy, "http://from-backup",
6962            "recovered from config.json.bak instead of losing all config"
6963        );
6964        assert!(
6965            dir_has_quarantine_file(&temp.path),
6966            "corrupt config.json was quarantined (preserved), not discarded"
6967        );
6968    }
6969
6970    #[test]
6971    fn corrupt_config_without_backup_quarantines_then_defaults() {
6972        let temp = TempHome::new();
6973        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
6974
6975        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6976        assert!(
6977            config.http_proxy.is_empty(),
6978            "no backup -> falls back to defaults"
6979        );
6980        assert!(
6981            dir_has_quarantine_file(&temp.path),
6982            "corrupt config.json is quarantined even when there's no backup"
6983        );
6984    }
6985
6986    #[test]
6987    fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
6988        let temp = TempHome::new();
6989        // A valid JSON OBJECT, but `env_vars` is the wrong type (string, not array)
6990        // so STRICT parse fails. There is NO config.json.bak, so recovery must come
6991        // from field-level salvage: `http_proxy` is valid and must survive; the bad
6992        // `env_vars` resets to its default.
6993        temp.set_config_json(
6994            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
6995        );
6996
6997        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
6998        assert_eq!(
6999            config.http_proxy, "http://salvaged",
7000            "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
7001        );
7002        assert!(
7003            config.env_vars.is_empty(),
7004            "the corrupt field reset to its default instead of failing the whole load"
7005        );
7006        assert!(
7007            dir_has_quarantine_file(&temp.path),
7008            "the corrupt config.json was still quarantined for inspection"
7009        );
7010    }
7011
7012    #[test]
7013    fn salvage_preferred_over_backup_for_most_recent_intent() {
7014        let temp = TempHome::new();
7015        // An OLDER last-known-good backup...
7016        std::fs::write(
7017            temp.path.join("config.json.bak"),
7018            serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
7019        )
7020        .unwrap();
7021        // ...and a NEWER config that is corrupt but field-salvageable.
7022        temp.set_config_json(
7023            r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
7024        );
7025
7026        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7027        assert_eq!(
7028            config.http_proxy, "http://new-salvaged",
7029            "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7030        );
7031    }
7032
7033    #[test]
7034    fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7035        let temp = TempHome::new();
7036        // Backup carries TWO good values.
7037        std::fs::write(
7038            temp.path.join("config.json.bak"),
7039            serde_json::json!({
7040                "http_proxy": "http://old-from-backup",
7041                "https_proxy": "https://kept-from-backup",
7042            })
7043            .to_string(),
7044        )
7045        .unwrap();
7046        // The corrupt file updates http_proxy (newer), leaves https_proxy untouched,
7047        // and has one wrong-type field.
7048        temp.set_config_json(
7049            r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7050        );
7051
7052        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7053        // Best of both: the corrupt file's newer valid edit wins where it set one...
7054        assert_eq!(
7055            config.http_proxy, "http://newer-edit",
7056            "the corrupt file's newer valid edit is applied"
7057        );
7058        // ...and the backup's value survives for fields the corrupt file didn't fix.
7059        assert_eq!(
7060            config.https_proxy, "https://kept-from-backup",
7061            "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7062        );
7063    }
7064
7065    #[test]
7066    fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7067        let temp = TempHome::new();
7068        std::fs::write(
7069            temp.path.join("config.json.bak"),
7070            serde_json::json!({
7071                "providers": {
7072                    "anthropic": { "model": "claude-backup" }
7073                },
7074                "memory": {
7075                    "auto_dream_enabled": true
7076                },
7077                "subagents": {
7078                    "claude_code_model": "claude-code-backup"
7079                }
7080            })
7081            .to_string(),
7082        )
7083        .unwrap();
7084        assert!(!temp.path.join("providers.json").exists());
7085        assert!(!temp.path.join("memory.json").exists());
7086        assert!(!temp.path.join("subagents.json").exists());
7087
7088        let (salvaged, recovered_fields) = Config::salvage_partial(
7089            r#"{"providers":"schema-invalid-provider-section"}"#,
7090            &temp.path,
7091        )
7092        .expect("object-shaped corrupt config should be salvageable");
7093
7094        assert!(
7095            recovered_fields.is_empty(),
7096            "the schema-invalid provider field must not replace the backup baseline"
7097        );
7098        assert_eq!(
7099            salvaged
7100                .providers()
7101                .anthropic
7102                .as_ref()
7103                .and_then(|provider| provider.model.as_deref()),
7104            Some("claude-backup")
7105        );
7106        assert!(
7107            salvaged
7108                .memory()
7109                .as_ref()
7110                .expect("backup memory config survives")
7111                .auto_dream_enabled
7112        );
7113        assert_eq!(
7114            salvaged.subagents().claude_code_model.as_deref(),
7115            Some("claude-code-backup")
7116        );
7117    }
7118
7119    #[test]
7120    fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7121        let temp = TempHome::new();
7122        // Not even a JSON object -> nothing field-wise to salvage -> must fall
7123        // through to the .bak (the pre-#135 behavior is preserved).
7124        std::fs::write(
7125            temp.path.join("config.json.bak"),
7126            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7127        )
7128        .unwrap();
7129        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7130
7131        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7132        assert_eq!(
7133            config.http_proxy, "http://from-backup",
7134            "garbage (non-object) config skips salvage and recovers from .bak"
7135        );
7136    }
7137
7138    #[test]
7139    fn quarantine_files_are_capped_to_newest_n() {
7140        let temp = TempHome::new();
7141        let config_path = temp.path.join("config.json");
7142        std::fs::write(&config_path, "{}").unwrap();
7143
7144        // Drop more quarantines than the cap; each call sleeps so nanos (the name)
7145        // and mtime (the prune sort key) are distinct.
7146        for _ in 0..(QUARANTINE_KEEP + 3) {
7147            quarantine_corrupt_config(&config_path);
7148            std::thread::sleep(std::time::Duration::from_millis(3));
7149        }
7150
7151        let count = std::fs::read_dir(&temp.path)
7152            .unwrap()
7153            .filter_map(|e| e.ok())
7154            .filter(|e| {
7155                e.file_name()
7156                    .to_string_lossy()
7157                    .starts_with("config.json.corrupted.")
7158            })
7159            .count();
7160        assert_eq!(
7161            count, QUARANTINE_KEEP,
7162            "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7163        );
7164    }
7165
7166    #[test]
7167    fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7168        let temp = TempHome::new();
7169        // Primary AND the freshest .bak are corrupt; an older generation is good.
7170        std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7171        std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7172        std::fs::write(
7173            temp.path.join("config.json.bak.1"),
7174            serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7175        )
7176        .unwrap();
7177
7178        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7179        assert_eq!(
7180            config.http_proxy, "http://from-gen-1",
7181            "recovered from .bak.1 when both config.json and .bak are corrupt"
7182        );
7183    }
7184
7185    #[test]
7186    fn save_rotates_backup_generations() {
7187        let temp = TempHome::new();
7188        let path = temp.path.join("config.json");
7189        // v1 is the existing on-disk config.
7190        std::fs::write(
7191            &path,
7192            serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7193        )
7194        .unwrap();
7195
7196        let mut cfg = Config::create_default();
7197        // Save 1: backs up the existing v1 -> .bak, writes v2.
7198        cfg.http_proxy = "http://proxy-v2".to_string();
7199        cfg.save_to_dir(temp.path.clone()).unwrap();
7200        // Save 2: existing (v2) is parseable -> rotate .bak(v1) -> .bak.1, .bak = v2.
7201        cfg.http_proxy = "http://proxy-v3".to_string();
7202        cfg.save_to_dir(temp.path.clone()).unwrap();
7203
7204        let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7205        let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7206        assert!(
7207            bak.contains("proxy-v2"),
7208            ".bak holds the previous generation (v2)"
7209        );
7210        assert!(
7211            bak1.contains("proxy-v1"),
7212            ".bak.1 holds the older rotated generation (v1)"
7213        );
7214    }
7215
7216    #[test]
7217    fn save_backs_up_existing_config() {
7218        let temp = TempHome::new();
7219        // Existing (old) config on disk.
7220        std::fs::write(
7221            temp.path.join("config.json"),
7222            serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7223        )
7224        .unwrap();
7225
7226        let mut config = Config::create_default();
7227        config.http_proxy = "http://new".to_string();
7228        config
7229            .save_to_dir(temp.path.clone())
7230            .expect("save succeeds");
7231
7232        let backup =
7233            std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7234        assert!(
7235            backup.contains("http://old"),
7236            "config.json.bak holds the PREVIOUS config (last-known-good)"
7237        );
7238        let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7239        assert!(
7240            current.contains("http://new"),
7241            "config.json holds the new config"
7242        );
7243    }
7244
7245    #[test]
7246    fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7247        let temp = TempHome::new();
7248        // A good last-known-good backup...
7249        std::fs::write(
7250            temp.path.join("config.json.bak"),
7251            serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7252        )
7253        .unwrap();
7254        // ...but the on-disk config.json is corrupt (as it would be right after an
7255        // in-memory recovery, before any clean save).
7256        std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7257
7258        let mut config = Config::create_default();
7259        config.http_proxy = "http://new".to_string();
7260        config
7261            .save_to_dir(temp.path.clone())
7262            .expect("save succeeds");
7263
7264        // The good .bak must NOT have been clobbered by the corrupt config.json.
7265        let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7266        assert!(
7267            backup.contains("http://good-bak"),
7268            "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7269        );
7270    }
7271
7272    // ── config-corruption recovery confirmation gate (#153) ───────────────
7273
7274    #[test]
7275    fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7276        let temp = TempHome::new();
7277        std::fs::write(
7278            temp.path.join("config.json.bak"),
7279            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7280        )
7281        .unwrap();
7282        let corrupt_bytes = "{ not valid json ";
7283        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7284
7285        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7286        let status = config
7287            .recovery_status()
7288            .expect("a corrupt load must set a pending recovery status");
7289        assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7290        assert_eq!(
7291            status.source,
7292            ConfigRecoverySource::Backup { generation: 0 },
7293            "recovered from generation-0 (.bak)"
7294        );
7295        let quarantine_path = status
7296            .quarantine_path
7297            .as_ref()
7298            .expect("quarantine copy should have succeeded");
7299        assert_eq!(
7300            std::fs::read_to_string(quarantine_path).unwrap(),
7301            corrupt_bytes,
7302            "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7303        );
7304        assert_eq!(
7305            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7306            corrupt_bytes,
7307            "the original config.json itself is untouched by the load (only copied, not moved)"
7308        );
7309    }
7310
7311    #[test]
7312    fn recovery_status_set_from_salvage_lists_recovered_fields() {
7313        let temp = TempHome::new();
7314        temp.set_config_json(
7315            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7316        );
7317
7318        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7319        let status = config.recovery_status().expect("pending recovery");
7320        assert!(!status.confirmed);
7321        match &status.source {
7322            ConfigRecoverySource::Salvaged { fields } => {
7323                assert!(
7324                    fields.iter().any(|f| f == "http_proxy"),
7325                    "salvaged fields should list the recovered key: {fields:?}"
7326                );
7327            }
7328            other => panic!("expected Salvaged source, got {other:?}"),
7329        }
7330    }
7331
7332    #[test]
7333    fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7334        let temp = TempHome::new();
7335        // Not a JSON object at all -> salvage impossible; no .bak -> defaults.
7336        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7337
7338        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7339        let status = config.recovery_status().expect("pending recovery");
7340        assert!(!status.confirmed);
7341        assert_eq!(status.source, ConfigRecoverySource::Defaults);
7342    }
7343
7344    #[test]
7345    fn clean_load_never_sets_recovery_status() {
7346        let temp = TempHome::new();
7347        temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7348
7349        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7350        assert!(
7351            config.recovery_status().is_none(),
7352            "a config.json that parses cleanly must never carry a pending recovery status"
7353        );
7354    }
7355
7356    #[test]
7357    fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7358        let temp = TempHome::new();
7359        let corrupt_bytes = "}}} broken";
7360        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7361
7362        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7363        assert!(config.recovery_status().is_some());
7364
7365        let err = config
7366            .save_to_dir(temp.path.clone())
7367            .expect_err("save must refuse while recovery is unconfirmed");
7368        assert!(
7369            err.to_string().contains("recovered from corruption")
7370                || err.to_string().contains("confirm"),
7371            "error should explain the refused overwrite: {err}"
7372        );
7373
7374        // The corrupt original on disk must be BYTE FOR BYTE unchanged — the
7375        // refused save must not have touched it at all.
7376        assert_eq!(
7377            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7378            corrupt_bytes,
7379            "a refused save must leave the corrupt original untouched"
7380        );
7381    }
7382
7383    #[test]
7384    fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7385        let temp = TempHome::new();
7386        // Simulates a crash mid-write: valid JSON prefix, abruptly cut off.
7387        let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7388        std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7389
7390        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7391        let status = config.recovery_status().expect("pending recovery");
7392        let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7393        assert_eq!(
7394            std::fs::read_to_string(quarantine_path).unwrap(),
7395            truncated,
7396            "truncated original preserved byte for byte in quarantine"
7397        );
7398
7399        let err = config.save_to_dir(temp.path.clone());
7400        assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7401        assert_eq!(
7402            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7403            truncated,
7404            "the half-written original stays exactly as it was after a refused save"
7405        );
7406    }
7407
7408    #[test]
7409    fn confirm_recovery_allows_the_next_save() {
7410        let temp = TempHome::new();
7411        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7412
7413        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7414        assert!(config.recovery_status().is_some());
7415
7416        config.confirm_recovery();
7417        assert!(
7418            config.recovery_status().is_some_and(|s| s.confirmed),
7419            "confirm_recovery flips the flag but keeps the status around"
7420        );
7421
7422        config
7423            .save_to_dir(temp.path.clone())
7424            .expect("save must succeed once the recovery is confirmed");
7425    }
7426
7427    #[test]
7428    fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7429        let temp = TempHome::new();
7430        std::fs::write(
7431            temp.path.join("config.json"),
7432            r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7433        )
7434        .unwrap();
7435
7436        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7437        assert!(config.recovery_status().is_some());
7438        let quarantine_path = config
7439            .recovery_status()
7440            .unwrap()
7441            .quarantine_path
7442            .clone()
7443            .unwrap();
7444
7445        config
7446            .confirm_recovery_and_save_to_dir(temp.path.clone())
7447            .expect("confirm+save should succeed");
7448
7449        assert!(
7450            config.recovery_status().is_none(),
7451            "the pending flag is cleared once the recovery is confirmed and persisted"
7452        );
7453        let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7454        assert!(
7455            on_disk.contains("http://recovered"),
7456            "config.json now holds the recovered (salvaged) state"
7457        );
7458        // The quarantine copy of the original corrupt file must still exist,
7459        // untouched, even after the recovery is confirmed and persisted.
7460        assert!(
7461            quarantine_path.exists(),
7462            "the quarantined original survives confirmation — it's never deleted"
7463        );
7464    }
7465
7466    #[test]
7467    fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7468        let temp = TempHome::new();
7469        let mut config = Config::create_default();
7470        let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7471        assert!(
7472            err.is_err(),
7473            "confirming a recovery that was never pending must error, not silently succeed"
7474        );
7475    }
7476
7477    // ── connect.json split (#455) ────────────────────────────────────────
7478
7479    fn connect_platform_with_encrypted(
7480        platform_type: &str,
7481        token_encrypted: &str,
7482    ) -> ConnectPlatformConfig {
7483        ConnectPlatformConfig {
7484            id: None,
7485            project_id: None,
7486            platform_type: platform_type.to_string(),
7487            token: None,
7488            token_encrypted: Some(token_encrypted.to_string()),
7489            token_credential_ref: None,
7490            token_configured: false,
7491            app_id: None,
7492            app_secret: None,
7493            app_secret_encrypted: None,
7494            app_secret_credential_ref: None,
7495            app_secret_configured: false,
7496            domain: None,
7497            allow_from: vec!["user-1".to_string()],
7498            admin_from: Vec::new(),
7499        }
7500    }
7501
7502    fn connect_json_path(temp: &TempHome) -> PathBuf {
7503        temp.path.join("connect.json")
7504    }
7505
7506    #[test]
7507    fn save_splits_connect_into_sibling_connect_json() {
7508        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7509        let temp = TempHome::new();
7510
7511        let mut config = Config::create_default();
7512        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7513        config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7514
7515        config
7516            .save_to_dir(temp.path.clone())
7517            .expect("save succeeds");
7518
7519        let config_json: serde_json::Value =
7520            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7521                .unwrap();
7522        assert!(
7523            config_json.get("connect").is_none(),
7524            "config.json must not carry the `connect` key after a save"
7525        );
7526
7527        let connect_json: serde_json::Value =
7528            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7529                .unwrap();
7530        assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7531        assert!(
7532            connect_json["platforms"][0]["token_encrypted"]
7533                .as_str()
7534                .is_some_and(|v| !v.is_empty()),
7535            "the token is persisted in its encrypted form in connect.json"
7536        );
7537        assert!(
7538            connect_json["platforms"][0].get("token").is_none(),
7539            "the plaintext token is never persisted (skip_serializing)"
7540        );
7541    }
7542
7543    // ── stable connect.platforms id (#496) ───────────────────────────────
7544
7545    #[test]
7546    fn save_assigns_a_missing_connect_platform_id() {
7547        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7548        let temp = TempHome::new();
7549
7550        let mut config = Config::create_default();
7551        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7552        assert!(
7553            config.connect.platforms[0].id.is_none(),
7554            "precondition: the entry starts without an id"
7555        );
7556
7557        config
7558            .save_to_dir(temp.path.clone())
7559            .expect("save succeeds");
7560
7561        let connect_json: serde_json::Value =
7562            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7563                .unwrap();
7564        let persisted_id = connect_json["platforms"][0]["id"]
7565            .as_str()
7566            .expect("save_to_dir must backfill a missing id onto the persisted entry");
7567        assert!(!persisted_id.is_empty());
7568
7569        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7570        assert_eq!(
7571            reloaded.connect.platforms[0].id.as_deref(),
7572            Some(persisted_id),
7573            "the assigned id round-trips through a reload"
7574        );
7575    }
7576
7577    #[test]
7578    fn save_never_reassigns_an_existing_connect_platform_id() {
7579        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7580        let temp = TempHome::new();
7581
7582        let mut config = Config::create_default();
7583        let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7584        platform.id = Some("stable-id-123".to_string());
7585        config.connect.platforms = vec![platform];
7586
7587        config
7588            .save_to_dir(temp.path.clone())
7589            .expect("first save succeeds");
7590        // Save again (e.g. an unrelated settings change) — the id must not change.
7591        config
7592            .save_to_dir(temp.path.clone())
7593            .expect("second save succeeds");
7594
7595        let connect_json: serde_json::Value =
7596            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7597                .unwrap();
7598        assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7599    }
7600
7601    #[test]
7602    fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7603        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7604        let temp = TempHome::new();
7605
7606        let mut config = Config::create_default();
7607        config.connect.platforms = vec![
7608            connect_platform_with_encrypted("telegram", "cipher-a"),
7609            connect_platform_with_encrypted("telegram", "cipher-b"),
7610        ];
7611
7612        config
7613            .save_to_dir(temp.path.clone())
7614            .expect("save succeeds");
7615
7616        let connect_json: serde_json::Value =
7617            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7618                .unwrap();
7619        let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7620        let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7621        assert_ne!(
7622            id_a, id_b,
7623            "two entries sharing platform_type must still get distinct ids"
7624        );
7625    }
7626
7627    #[test]
7628    fn load_never_assigns_or_persists_an_id_by_itself() {
7629        let temp = TempHome::new();
7630        std::fs::write(
7631            connect_json_path(&temp),
7632            serde_json::json!({
7633                "platforms": [
7634                    { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7635                ]
7636            })
7637            .to_string(),
7638        )
7639        .unwrap();
7640        let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7641
7642        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7643
7644        assert!(
7645            config.connect.platforms[0].id.is_none(),
7646            "load alone must not backfill an id in memory"
7647        );
7648        let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7649        assert_eq!(
7650            connect_json_before, connect_json_after,
7651            "load must never rewrite connect.json on disk just to backfill an id (#493)"
7652        );
7653    }
7654
7655    #[test]
7656    fn load_merges_connect_json_into_config() {
7657        let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7658        let temp = TempHome::new();
7659        let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7660        std::fs::write(
7661            connect_json_path(&temp),
7662            serde_json::json!({
7663                "platforms": [
7664                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7665                ]
7666            })
7667            .to_string(),
7668        )
7669        .unwrap();
7670
7671        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7672        assert_eq!(config.connect.platforms.len(), 1);
7673        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7674        assert_eq!(
7675            config.connect.platforms[0].token.as_deref(),
7676            Some("connect-secret")
7677        );
7678        assert!(config.connect.platforms[0].token_encrypted.is_none());
7679    }
7680
7681    #[test]
7682    fn load_without_connect_json_yields_empty_inert_connect_config() {
7683        let temp = TempHome::new();
7684        temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7685
7686        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7687        assert!(
7688            config.connect.platforms.is_empty(),
7689            "no connect.json and no legacy key -> empty/inert connect config"
7690        );
7691        assert!(
7692            !connect_json_path(&temp).exists(),
7693            "load must not create connect.json when there is nothing to migrate"
7694        );
7695    }
7696
7697    #[test]
7698    fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7699        let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7700        let temp = TempHome::new();
7701        let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7702        let legacy_cipher_for_assert = legacy_cipher.clone();
7703        // Legacy state (#453): connect lives inline in config.json, no connect.json yet.
7704        temp.set_config_json(
7705            &serde_json::json!({
7706                "http_proxy": "http://keep-me",
7707                "connect": {
7708                    "platforms": [
7709                        { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7710                    ]
7711                }
7712            })
7713            .to_string(),
7714        );
7715
7716        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7717
7718        // In-memory: legacy value adopted.
7719        assert_eq!(config.connect.platforms.len(), 1);
7720        assert_eq!(
7721            config.connect.platforms[0].token.as_deref(),
7722            Some("legacy-connect-secret")
7723        );
7724        // An unrelated field from the same load survives the migration rewrite.
7725        assert_eq!(config.http_proxy, "http://keep-me");
7726
7727        // On disk: connect.json was created...
7728        assert!(
7729            connect_json_path(&temp).exists(),
7730            "migration proactively creates connect.json"
7731        );
7732        let connect_json: serde_json::Value =
7733            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7734                .unwrap();
7735        assert_eq!(
7736            connect_json["platforms"][0]["token_encrypted"],
7737            legacy_cipher_for_assert
7738        );
7739
7740        // ...and config.json was rewritten without the `connect` key.
7741        let config_json: serde_json::Value =
7742            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7743                .unwrap();
7744        assert!(
7745            config_json.get("connect").is_none(),
7746            "config.json is rewritten without the legacy `connect` key"
7747        );
7748    }
7749
7750    /// #457: the legacy-key migration must be a NARROW write (strip `connect`
7751    /// from config.json + write connect.json) — not the full `save_to_dir`,
7752    /// which would re-encrypt every OTHER secret in config.json and rotate a
7753    /// `config.json.bak` generation as a load-time side effect. This matters
7754    /// most for a purely READ-ONLY command (e.g. `bamboo config get`) run on a
7755    /// machine that still has the legacy `connect` key: it must not silently
7756    /// rewrite/re-encrypt unrelated secrets or spin up a backup.
7757    #[test]
7758    fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
7759        let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
7760        let temp = TempHome::new();
7761
7762        let original_api_key_encrypted =
7763            crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
7764        temp.set_config_json(
7765            &serde_json::json!({
7766                "providers": {
7767                    "openai": {
7768                        "api_key_encrypted": original_api_key_encrypted,
7769                    }
7770                },
7771                "connect": {
7772                    "platforms": [
7773                        { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
7774                    ]
7775                }
7776            })
7777            .to_string(),
7778        );
7779
7780        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7781        assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
7782
7783        // No `config.json.bak` — the narrow write does not rotate backups the
7784        // way a full `save_to_dir` would.
7785        assert!(
7786            !temp.path.join("config.json.bak").exists(),
7787            "a read-only load migrating a legacy `connect` key must not rotate \
7788             config.json backups"
7789        );
7790
7791        // The unrelated provider secret's ciphertext is byte-for-byte
7792        // unchanged — proof it was never decrypted+re-encrypted (encryption
7793        // uses a random nonce per call, so any re-encryption would change the
7794        // bytes even for the same plaintext).
7795        let config_json: serde_json::Value =
7796            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7797                .unwrap();
7798        assert_eq!(
7799            config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
7800            "an unrelated secret's ciphertext must not be touched by the connect \
7801             migration's narrow write"
7802        );
7803        assert!(
7804            config_json.get("connect").is_none(),
7805            "config.json is still rewritten without the legacy `connect` key"
7806        );
7807    }
7808
7809    #[test]
7810    fn both_files_present_connect_json_wins() {
7811        let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
7812        let temp = TempHome::new();
7813        let stale = crate::encryption::encrypt("stale-secret").unwrap();
7814        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7815        temp.set_config_json(
7816            &serde_json::json!({
7817                "connect": {
7818                    "platforms": [
7819                        { "type": "telegram", "token_encrypted": stale }
7820                    ]
7821                }
7822            })
7823            .to_string(),
7824        );
7825        std::fs::write(
7826            connect_json_path(&temp),
7827            serde_json::json!({
7828                "platforms": [
7829                    { "type": "telegram", "token_encrypted": authoritative }
7830                ]
7831            })
7832            .to_string(),
7833        )
7834        .unwrap();
7835
7836        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7837        assert_eq!(
7838            config.connect.platforms[0].token.as_deref(),
7839            Some("authoritative-secret"),
7840            "connect.json wins over a stale legacy config.json key"
7841        );
7842    }
7843
7844    /// #457: when both files are present, the superseded `connect` key in
7845    /// config.json must be stripped PROACTIVELY on load — not left to linger
7846    /// until the next natural save, which spreads token ciphertext across two
7847    /// files for longer than necessary.
7848    #[test]
7849    fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
7850        let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
7851        let temp = TempHome::new();
7852        let stale = crate::encryption::encrypt("stale-secret").unwrap();
7853        let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
7854        temp.set_config_json(
7855            &serde_json::json!({
7856                "http_proxy": "http://keep-me",
7857                "connect": {
7858                    "platforms": [
7859                        { "type": "telegram", "token_encrypted": stale }
7860                    ]
7861                }
7862            })
7863            .to_string(),
7864        );
7865        std::fs::write(
7866            connect_json_path(&temp),
7867            serde_json::json!({
7868                "platforms": [
7869                    { "type": "telegram", "token_encrypted": authoritative }
7870                ]
7871            })
7872            .to_string(),
7873        )
7874        .unwrap();
7875
7876        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7877        assert_eq!(
7878            config.connect.platforms[0].token.as_deref(),
7879            Some("authoritative-secret")
7880        );
7881        // Unrelated field survives the strip.
7882        assert_eq!(config.http_proxy, "http://keep-me");
7883
7884        let config_json: serde_json::Value =
7885            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7886                .unwrap();
7887        assert!(
7888            config_json.get("connect").is_none(),
7889            "the stale legacy `connect` key must be stripped from config.json \
7890             immediately on load, not left for the next natural save"
7891        );
7892    }
7893
7894    /// #468 (follow-up to #457): a `.bak` generation that predates the #455
7895    /// split can still carry the legacy embedded `connect` sub-tree even
7896    /// after the LIVE config.json has long since been migrated (a clean
7897    /// config.json here, with no `connect` key at all, proves the sweep does
7898    /// not depend on the current-load migration path having just fired).
7899    /// Only the tainted generation is rewritten; every other key in it
7900    /// survives, and the rewrite strips exactly the `connect` key.
7901    #[test]
7902    fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
7903        let temp = TempHome::new();
7904        temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
7905        std::fs::write(
7906            temp.path.join("config.json.bak"),
7907            serde_json::json!({
7908                "http_proxy": "http://old",
7909                "connect": {
7910                    "platforms": [
7911                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
7912                    ]
7913                }
7914            })
7915            .to_string(),
7916        )
7917        .unwrap();
7918
7919        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7920        // The live config is unaffected — connect.json never existed and
7921        // config.json never had the key, so in-memory connect stays empty.
7922        assert!(config.connect.platforms.is_empty());
7923
7924        let bak: serde_json::Value = serde_json::from_str(
7925            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
7926        )
7927        .unwrap();
7928        assert!(
7929            bak.get("connect").is_none(),
7930            "the legacy `connect` key must be stripped from the tainted .bak generation"
7931        );
7932        assert_eq!(
7933            bak["http_proxy"], "http://old",
7934            "every other key in the .bak generation survives the scrub byte-for-byte in content"
7935        );
7936    }
7937
7938    /// The sweep must touch EVERY rotated generation that carries the legacy
7939    /// key, not just `.bak` — an upgraded instance can have the taint several
7940    /// generations deep depending on how many saves happened since #455/#457
7941    /// shipped but before this fix.
7942    #[test]
7943    fn scrub_reaches_all_rotated_generations() {
7944        let temp = TempHome::new();
7945        temp.set_config_json(&serde_json::json!({}).to_string());
7946        for (gen_suffix, cipher) in [
7947            ("config.json.bak", "cipher-gen0"),
7948            ("config.json.bak.1", "cipher-gen1"),
7949            ("config.json.bak.2", "cipher-gen2"),
7950        ] {
7951            std::fs::write(
7952                temp.path.join(gen_suffix),
7953                serde_json::json!({
7954                    "connect": {
7955                        "platforms": [
7956                            { "type": "telegram", "token_encrypted": cipher }
7957                        ]
7958                    }
7959                })
7960                .to_string(),
7961            )
7962            .unwrap();
7963        }
7964
7965        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7966
7967        for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
7968            let value: serde_json::Value =
7969                serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
7970                    .unwrap();
7971            assert!(
7972                value.get("connect").is_none(),
7973                "{gen_suffix} must have its legacy `connect` key stripped"
7974            );
7975        }
7976    }
7977
7978    /// A `.bak` generation with NO legacy `connect` key must be left
7979    /// completely untouched by the sweep — not even a byte-identical
7980    /// rewrite — preserving the file's bytes/mtime exactly. This is the
7981    /// overwhelming common case (any backup created after #455/#457 shipped)
7982    /// and the whole point of the surgical, only-touch-what's-tainted
7983    /// approach: `.bak` files are the user's recovery net (#493) and
7984    /// shouldn't be churned by an unrelated sweep.
7985    #[test]
7986    fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
7987        let temp = TempHome::new();
7988        temp.set_config_json(&serde_json::json!({}).to_string());
7989        let bak_path = temp.path.join("config.json.bak");
7990        std::fs::write(
7991            &bak_path,
7992            serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
7993        )
7994        .unwrap();
7995
7996        let before_bytes = std::fs::read(&bak_path).unwrap();
7997        let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
7998
7999        // A tiny sleep would make an mtime-changed assertion more robust, but
8000        // even without one, a same-mtime filesystem is the STRONGER
8001        // guarantee of "no write happened" — good enough on its own.
8002        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8003
8004        let after_bytes = std::fs::read(&bak_path).unwrap();
8005        let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8006        assert_eq!(
8007            before_bytes, after_bytes,
8008            "a .bak generation without a legacy `connect` key must not be rewritten at all"
8009        );
8010        assert_eq!(
8011            before_mtime, after_mtime,
8012            "no write means no mtime change either"
8013        );
8014    }
8015
8016    /// An unparsable `.bak` generation (corrupt/foreign content) must be
8017    /// skipped, not deleted and not guessed at — it's left exactly as found
8018    /// so an operator can inspect it by hand, matching the same fail-safe
8019    /// posture as the rest of the backup/quarantine machinery.
8020    #[test]
8021    fn scrub_skips_unparsable_backup_without_deleting_it() {
8022        let temp = TempHome::new();
8023        temp.set_config_json(&serde_json::json!({}).to_string());
8024        std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
8025
8026        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8027
8028        let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8029        assert_eq!(
8030            content, "{ not valid json",
8031            "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8032        );
8033    }
8034
8035    /// A missing generation (e.g. only `.bak` exists, no `.bak.1`/`.bak.2`
8036    /// yet) must not trip an error — it's the common case for a young
8037    /// install and the sweep should just skip straight past it.
8038    #[test]
8039    fn scrub_tolerates_missing_generations() {
8040        let temp = TempHome::new();
8041        temp.set_config_json(&serde_json::json!({}).to_string());
8042        // No .bak files at all.
8043        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8044        assert!(config.connect.platforms.is_empty());
8045        assert!(!temp.path.join("config.json.bak").exists());
8046    }
8047
8048    /// The scrub sweep must not interfere with normal backup rotation on
8049    /// subsequent saves — rotation keeps working exactly as before.
8050    #[test]
8051    fn scrub_does_not_break_backup_rotation() {
8052        let temp = TempHome::new();
8053        std::fs::write(
8054            temp.path.join("config.json"),
8055            serde_json::json!({
8056                "http_proxy": "http://proxy-v1",
8057                "connect": {
8058                    "platforms": [
8059                        { "type": "telegram", "token_encrypted": "legacy-cipher" }
8060                    ]
8061                }
8062            })
8063            .to_string(),
8064        )
8065        .unwrap();
8066        std::fs::write(
8067            temp.path.join("config.json.bak"),
8068            serde_json::json!({
8069                "http_proxy": "http://proxy-v0",
8070                "connect": {
8071                    "platforms": [
8072                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8073                    ]
8074                }
8075            })
8076            .to_string(),
8077        )
8078        .unwrap();
8079
8080        // Load triggers: migration of the live legacy key + the .bak sweep.
8081        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8082        let bak: serde_json::Value = serde_json::from_str(
8083            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8084        )
8085        .unwrap();
8086        assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8087
8088        // Rotation still works on a subsequent save: v_current -> .bak,
8089        // .bak(old) -> .bak.1.
8090        config.http_proxy = "http://proxy-v2".to_string();
8091        config.save_to_dir(temp.path.clone()).unwrap();
8092
8093        let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8094        assert!(
8095            new_bak.contains("proxy-v1"),
8096            ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8097        );
8098        let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8099        assert!(
8100            new_bak1.contains("proxy-v0"),
8101            ".bak.1 holds the scrubbed older generation after rotation"
8102        );
8103        assert!(
8104            !new_bak1.contains("legacy-bak-cipher"),
8105            "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8106             stripped secret"
8107        );
8108    }
8109
8110    #[test]
8111    fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8112        let temp = TempHome::new();
8113        temp.set_config_json("{}");
8114        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8115
8116        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8117        assert!(
8118            config.connect.platforms.is_empty(),
8119            "corrupt connect.json fails SAFE to an empty/inert connect config"
8120        );
8121
8122        let backup = connect_json_path(&temp).with_extension("json.bak");
8123        assert!(
8124            backup.exists(),
8125            "the corrupt connect.json is quarantined to connect.json.bak"
8126        );
8127        assert!(
8128            std::fs::read_to_string(backup)
8129                .unwrap()
8130                .contains("not valid json"),
8131            "the quarantined copy holds the bad content"
8132        );
8133        // #457: quarantine MOVES the corrupt file rather than copying it, so
8134        // the data dir doesn't end up with two copies of the same corrupt
8135        // content (the live `connect.json` and its `.bak`) sitting side by
8136        // side, which reads as confusing/ambiguous mid-incident.
8137        assert!(
8138            !connect_json_path(&temp).exists(),
8139            "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8140             connect.json should remain after quarantine"
8141        );
8142    }
8143
8144    #[test]
8145    fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8146        let temp = TempHome::new();
8147        // A legacy inline `connect` key is present too — it must NOT be used as a
8148        // fallback when connect.json is corrupt (security-sensitive: fail safe).
8149        temp.set_config_json(
8150            &serde_json::json!({
8151                "connect": {
8152                    "platforms": [
8153                        { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8154                    ]
8155                }
8156            })
8157            .to_string(),
8158        );
8159        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8160
8161        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8162        assert!(
8163            config.connect.platforms.is_empty(),
8164            "corrupt connect.json must not fall back to the legacy config.json copy"
8165        );
8166    }
8167
8168    #[test]
8169    fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8170        let temp = TempHome::new();
8171        let config = Config::create_default();
8172        assert!(config.connect.platforms.is_empty());
8173
8174        config
8175            .save_to_dir(temp.path.clone())
8176            .expect("save succeeds");
8177
8178        assert!(
8179            !connect_json_path(&temp).exists(),
8180            "an empty connect config with no pre-existing file must not create one"
8181        );
8182    }
8183
8184    #[test]
8185    fn connect_json_backed_up_before_overwrite() {
8186        let temp = TempHome::new();
8187        std::fs::write(
8188            connect_json_path(&temp),
8189            serde_json::json!({
8190                "platforms": [
8191                    { "type": "telegram", "token_encrypted": "old-cipher" }
8192                ]
8193            })
8194            .to_string(),
8195        )
8196        .unwrap();
8197
8198        let mut config = Config::create_default();
8199        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8200        config
8201            .save_to_dir(temp.path.clone())
8202            .expect("save succeeds");
8203
8204        let backup = connect_json_path(&temp).with_extension("json.bak");
8205        assert!(
8206            std::fs::read_to_string(backup)
8207                .unwrap()
8208                .contains("old-cipher"),
8209            "the previous connect.json is preserved as connect.json.bak before the overwrite"
8210        );
8211        let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8212        assert!(current.contains("new-cipher"));
8213    }
8214
8215    // ── Feishu adapter config fields (epic #447 phase 3, §2a) ───────────
8216
8217    #[test]
8218    fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8219        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8220        let temp = TempHome::new();
8221
8222        let mut config = Config::create_default();
8223        config.connect.platforms = vec![ConnectPlatformConfig {
8224            id: None,
8225            project_id: None,
8226            platform_type: "feishu".to_string(),
8227            token: None,
8228            token_encrypted: None,
8229            token_credential_ref: None,
8230            token_configured: false,
8231            app_id: Some("cli_real_app_id".to_string()),
8232            app_secret: Some("plain-app-secret".to_string()),
8233            app_secret_encrypted: None,
8234            app_secret_credential_ref: None,
8235            app_secret_configured: false,
8236            domain: Some("lark".to_string()),
8237            allow_from: vec!["ou_1".to_string()],
8238            admin_from: Vec::new(),
8239        }];
8240
8241        config
8242            .save_to_dir(temp.path.clone())
8243            .expect("save succeeds");
8244
8245        let connect_json: serde_json::Value =
8246            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8247                .unwrap();
8248        assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8249        assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8250        assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8251        assert!(
8252            connect_json["platforms"][0]["app_secret_encrypted"]
8253                .as_str()
8254                .is_some_and(|v| !v.is_empty()),
8255            "app_secret is persisted in its encrypted form in connect.json"
8256        );
8257        assert!(
8258            connect_json["platforms"][0].get("app_secret").is_none(),
8259            "the plaintext app_secret is never persisted (skip_serializing)"
8260        );
8261    }
8262
8263    #[test]
8264    fn load_hydrates_feishu_app_secret_from_encrypted() {
8265        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8266        let temp = TempHome::new();
8267
8268        let mut config = Config::create_default();
8269        config.connect.platforms = vec![ConnectPlatformConfig {
8270            id: None,
8271            project_id: None,
8272            platform_type: "feishu".to_string(),
8273            token: None,
8274            token_encrypted: None,
8275            token_credential_ref: None,
8276            token_configured: false,
8277            app_id: Some("cli_real_app_id".to_string()),
8278            app_secret: Some("plain-app-secret".to_string()),
8279            app_secret_encrypted: None,
8280            app_secret_credential_ref: None,
8281            app_secret_configured: false,
8282            domain: Some("lark".to_string()),
8283            allow_from: vec!["ou_1".to_string()],
8284            admin_from: Vec::new(),
8285        }];
8286        config
8287            .save_to_dir(temp.path.clone())
8288            .expect("save succeeds");
8289
8290        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8291        assert_eq!(reloaded.connect.platforms.len(), 1);
8292        assert_eq!(
8293            reloaded.connect.platforms[0].app_secret.as_deref(),
8294            Some("plain-app-secret"),
8295            "reload hydrates app_secret from app_secret_encrypted"
8296        );
8297        assert_eq!(
8298            reloaded.connect.platforms[0].app_id.as_deref(),
8299            Some("cli_real_app_id")
8300        );
8301        assert_eq!(
8302            reloaded.connect.platforms[0].domain.as_deref(),
8303            Some("lark")
8304        );
8305    }
8306
8307    #[test]
8308    fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8309        let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8310        let temp = TempHome::new();
8311        let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8312        std::fs::write(
8313            connect_json_path(&temp),
8314            serde_json::json!({
8315                "platforms": [
8316                    { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8317                ]
8318            })
8319            .to_string(),
8320        )
8321        .unwrap();
8322
8323        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8324
8325        assert_eq!(config.connect.platforms.len(), 1);
8326        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8327        assert_eq!(
8328            config.connect.platforms[0].token.as_deref(),
8329            Some("legacy-telegram-secret")
8330        );
8331        assert_eq!(
8332            config.connect.platforms[0].app_id, None,
8333            "a legacy entry with no Feishu fields deserializes them as None"
8334        );
8335        assert_eq!(config.connect.platforms[0].app_secret, None);
8336        assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8337        assert_eq!(config.connect.platforms[0].domain, None);
8338    }
8339
8340    #[test]
8341    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8342        let _lock = env_lock_acquire();
8343        let temp_home = TempHome::new();
8344        temp_home.set_config_json(
8345            r#"{
8346  "provider": "openai",
8347  "providers": {
8348    "openai": {
8349      "api_key": "sk-test",
8350      "model": "gpt-4o"
8351    }
8352  }
8353}"#,
8354        );
8355
8356        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8357        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8358
8359        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8360
8361        assert_eq!(
8362            config
8363                .providers
8364                .openai
8365                .as_ref()
8366                .and_then(|c| c.model.as_deref()),
8367            Some("gpt-4o")
8368        );
8369        assert!(
8370            config.http_proxy.is_empty(),
8371            "config should keep http_proxy empty when field is omitted"
8372        );
8373        assert!(
8374            config.https_proxy.is_empty(),
8375            "config should keep https_proxy empty when field is omitted"
8376        );
8377    }
8378
8379    #[test]
8380    fn get_memory_background_model_prefers_memory_specific_override() {
8381        let mut config = Config::default();
8382        config.features.provider_model_ref = false;
8383        config.provider = "openai".to_string();
8384        config.providers.openai = Some(OpenAIConfig {
8385            api_key: "test".to_string(),
8386            api_key_encrypted: None,
8387            credential_ref: None,
8388            base_url: None,
8389            model: Some("gpt-main".to_string()),
8390            fast_model: Some("gpt-fast".to_string()),
8391            vision_model: None,
8392            reasoning_effort: None,
8393            responses_only_models: vec![],
8394            request_overrides: None,
8395            extra: BTreeMap::new(),
8396            api_key_from_env: false,
8397        });
8398        config.memory.0 = Some(MemoryConfig {
8399            background_model: Some("memory-fast".to_string()),
8400            ..MemoryConfig::default()
8401        });
8402
8403        assert_eq!(
8404            config.get_memory_background_model().as_deref(),
8405            Some("memory-fast")
8406        );
8407    }
8408
8409    #[test]
8410    fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8411        // #373: the settings-PATCH serde round-trip drops every provider's
8412        // skip_serializing api_key; an env-sourced key (no ciphertext) can't be
8413        // re-hydrated, so it must be copied back from the live `current` config —
8414        // but an explicitly re-set key and non-env keys must NOT be touched.
8415        let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8416            api_key: api_key.to_string(),
8417            api_key_encrypted: None,
8418            credential_ref: None,
8419            base_url: None,
8420            model: None,
8421            fast_model: None,
8422            vision_model: None,
8423            reasoning_effort: None,
8424            responses_only_models: vec![],
8425            request_overrides: None,
8426            extra: BTreeMap::new(),
8427            api_key_from_env: from_env,
8428        };
8429
8430        // Env-sourced key dropped by the round-trip → restored.
8431        let mut current = Config::default();
8432        current.providers.openai = Some(openai("sk-env", true));
8433        let mut merged = Config::default();
8434        merged.providers.openai = Some(openai("", false)); // post-round-trip
8435        merged.preserve_env_sourced_provider_keys(&current);
8436        let got = merged.providers.openai.as_ref().unwrap();
8437        assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8438        assert!(got.api_key_from_env, "env flag restored");
8439
8440        // A key explicitly re-set by the patch is NOT overridden.
8441        let mut merged = Config::default();
8442        merged.providers.openai = Some(openai("sk-explicit", false));
8443        merged.preserve_env_sourced_provider_keys(&current);
8444        assert_eq!(
8445            merged.providers.openai.as_ref().unwrap().api_key,
8446            "sk-explicit",
8447            "explicit patch key must win"
8448        );
8449
8450        // A non-env key in current is NOT restored here (that's ciphertext hydration's job).
8451        let mut current_plain = Config::default();
8452        current_plain.providers.openai = Some(openai("sk-plain", false));
8453        let mut merged = Config::default();
8454        merged.providers.openai = Some(openai("", false));
8455        merged.preserve_env_sourced_provider_keys(&current_plain);
8456        assert!(
8457            merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8458            "non-env key must not be restored by this path"
8459        );
8460    }
8461
8462    #[test]
8463    fn refresh_preserves_ciphertext_when_plaintext_empty() {
8464        // #268: a provider whose stored ciphertext failed to decrypt at hydration
8465        // has an empty in-memory api_key. An unrelated later save must NOT null its
8466        // ciphertext — that would permanently drop a key the user never touched.
8467        let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8468            api_key: api_key.to_string(),
8469            api_key_encrypted: enc.map(str::to_string),
8470            credential_ref: None,
8471            base_url: None,
8472            model: None,
8473            fast_model: None,
8474            vision_model: None,
8475            reasoning_effort: None,
8476            responses_only_models: vec![],
8477            request_overrides: None,
8478            extra: BTreeMap::new(),
8479            api_key_from_env: false,
8480        };
8481
8482        // Empty plaintext + existing ciphertext → ciphertext preserved (the bug).
8483        let mut config = Config::default();
8484        config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8485        config
8486            .refresh_provider_api_keys_encrypted()
8487            .expect("refresh");
8488        assert_eq!(
8489            config
8490                .providers
8491                .openai
8492                .as_ref()
8493                .unwrap()
8494                .api_key_encrypted
8495                .as_deref(),
8496            Some("preexisting-ciphertext"),
8497            "existing ciphertext must be preserved when plaintext is empty"
8498        );
8499
8500        // Empty plaintext + no ciphertext → stays None (nothing to preserve).
8501        let mut config = Config::default();
8502        config.providers.openai = Some(openai("", None));
8503        config
8504            .refresh_provider_api_keys_encrypted()
8505            .expect("refresh");
8506        assert!(
8507            config
8508                .providers
8509                .openai
8510                .as_ref()
8511                .unwrap()
8512                .api_key_encrypted
8513                .is_none(),
8514            "no key + no ciphertext should stay None"
8515        );
8516
8517        // Non-empty plaintext → (re)encrypted to a fresh, non-empty ciphertext.
8518        let mut config = Config::default();
8519        config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8520        config
8521            .refresh_provider_api_keys_encrypted()
8522            .expect("refresh");
8523        let enc = config
8524            .providers
8525            .openai
8526            .as_ref()
8527            .unwrap()
8528            .api_key_encrypted
8529            .clone()
8530            .expect("ciphertext present");
8531        assert!(
8532            !enc.is_empty() && enc != "stale-ciphertext",
8533            "plaintext re-encrypted"
8534        );
8535    }
8536
8537    #[test]
8538    fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8539        // #516: `save_to_dir` refreshes ciphertext only on its save-time clone,
8540        // so a provider instance created over HTTP stays plaintext-only in the
8541        // live config. Serializing that live config (as the settings-PATCH
8542        // merge does) drops the `skip_serializing` plaintext and the key is
8543        // gone. `refresh_encrypted_secrets` on the live config closes the gap.
8544        let mut config = Config::default();
8545        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8546            "provider_type": "openai",
8547            "api_key": "sk-instance-live",
8548        }))
8549        .expect("valid instance");
8550        config
8551            .provider_instances
8552            .insert("work".to_string(), instance);
8553
8554        config.refresh_encrypted_secrets().expect("refresh");
8555        assert!(
8556            config.provider_instances["work"]
8557                .api_key_encrypted
8558                .is_some(),
8559            "live config must hold ciphertext after refresh"
8560        );
8561
8562        // The build_merged_config-style round-trip.
8563        let value = serde_json::to_value(&config).expect("serialize");
8564        let mut back: Config = serde_json::from_value(value).expect("deserialize");
8565        assert!(
8566            back.provider_instances["work"].api_key.is_empty(),
8567            "plaintext is skip_serializing"
8568        );
8569        back.hydrate_provider_instance_api_keys_from_encrypted();
8570        assert_eq!(
8571            back.provider_instances["work"].api_key, "sk-instance-live",
8572            "key must be recoverable from the round-tripped ciphertext"
8573        );
8574    }
8575
8576    #[test]
8577    fn get_memory_background_model_falls_back_to_provider_fast_model() {
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: "test".to_string(),
8583            api_key_encrypted: None,
8584            credential_ref: None,
8585            base_url: None,
8586            model: Some("gpt-main".to_string()),
8587            fast_model: Some("gpt-fast".to_string()),
8588            vision_model: None,
8589            reasoning_effort: None,
8590            responses_only_models: vec![],
8591            request_overrides: None,
8592            extra: BTreeMap::new(),
8593            api_key_from_env: false,
8594        });
8595
8596        assert_eq!(
8597            config.get_memory_background_model().as_deref(),
8598            Some("gpt-fast")
8599        );
8600    }
8601
8602    #[test]
8603    fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8604        let mut config = Config::default();
8605        config.features.provider_model_ref = false;
8606        config.provider = "openai".to_string();
8607        config.providers.openai = Some(OpenAIConfig {
8608            api_key: "sk-stale".to_string(),
8609            model: Some("legacy-main".to_string()),
8610            fast_model: Some("legacy-fast".to_string()),
8611            vision_model: Some("legacy-vision".to_string()),
8612            reasoning_effort: Some(ReasoningEffort::Low),
8613            ..OpenAIConfig::default()
8614        });
8615        let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8616            "provider_type": "openai",
8617            "model": "instance-main",
8618            "fast_model": "instance-fast",
8619            "vision_model": "instance-vision",
8620            "reasoning_effort": "high",
8621            "enabled": true
8622        }))
8623        .unwrap();
8624        instance.api_key = "sk-instance".to_string();
8625        config
8626            .provider_instances
8627            .insert("work".to_string(), instance);
8628        config.default_provider_instance = Some("work".to_string());
8629
8630        assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8631        assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8632        assert_eq!(
8633            config.get_memory_background_model().as_deref(),
8634            Some("instance-fast")
8635        );
8636        assert_eq!(
8637            config.get_task_summary_model().as_deref(),
8638            Some("instance-fast")
8639        );
8640        assert_eq!(
8641            config.get_vision_model().as_deref(),
8642            Some("instance-vision")
8643        );
8644        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8645    }
8646
8647    #[test]
8648    fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8649        let mut config = Config::default();
8650        config.features.provider_model_ref = true;
8651        config.provider_instances.insert(
8652            "compatibility-default".to_string(),
8653            serde_json::from_value(serde_json::json!({
8654                "provider_type": "openai",
8655                "reasoning_effort": "low",
8656                "enabled": true
8657            }))
8658            .unwrap(),
8659        );
8660        config.provider_instances.insert(
8661            "chat-provider".to_string(),
8662            serde_json::from_value(serde_json::json!({
8663                "provider_type": "anthropic",
8664                "reasoning_effort": "high",
8665                "enabled": true
8666            }))
8667            .unwrap(),
8668        );
8669        config.default_provider_instance = Some("compatibility-default".to_string());
8670        config.defaults = Some(
8671            serde_json::from_value(serde_json::json!({
8672                "chat": { "provider": "chat-provider", "model": "claude" }
8673            }))
8674            .unwrap(),
8675        );
8676
8677        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8678
8679        config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8680        assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8681    }
8682
8683    #[test]
8684    fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8685        let _provider =
8686            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8687        let _openai_key = crate::test_support::override_runtime_env_var(
8688            "BAMBOO_OPENAI_API_KEY",
8689            Some("sk-runtime-env-instance"),
8690        );
8691        let _anthropic_key =
8692            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8693        let _gemini_key =
8694            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8695
8696        let mut config = Config::default();
8697        *config.providers_mut() = ProviderConfigs::default();
8698        for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8699            let mut extra = serde_json::Map::new();
8700            if from_environment {
8701                extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8702            }
8703            extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8704            extra.insert("enabled".to_string(), serde_json::json!(true));
8705            config.provider_instances.insert(
8706                id.to_string(),
8707                serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8708            );
8709        }
8710
8711        config.apply_runtime_env_overrides();
8712
8713        assert_eq!(
8714            config.default_provider_instance.as_deref(),
8715            Some("a-marked"),
8716            "type override selects the lexicographically first enabled instance"
8717        );
8718        assert_eq!(
8719            config.provider_instances["a-marked"].api_key,
8720            "sk-runtime-env-instance"
8721        );
8722        assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
8723        assert!(
8724            config.providers().openai.is_none(),
8725            "instance-mode env hydration must not recreate a legacy alias"
8726        );
8727
8728        config.refresh_encrypted_secrets().unwrap();
8729        let serialized = serde_json::to_string(&config).unwrap();
8730        assert!(!serialized.contains("sk-runtime-env-instance"));
8731        assert!(
8732            config.provider_instances["a-marked"]
8733                .api_key_encrypted
8734                .is_none(),
8735            "runtime env plaintext must not be encrypted into ordinary config"
8736        );
8737    }
8738
8739    #[test]
8740    fn runtime_provider_override_prefers_exact_instance_id() {
8741        let _provider =
8742            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
8743        let _openai_key =
8744            crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
8745        let _anthropic_key =
8746            crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8747        let _gemini_key =
8748            crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8749        let mut config = Config::default();
8750        for id in ["work", "personal"] {
8751            config.provider_instances.insert(
8752                id.to_string(),
8753                serde_json::from_value(serde_json::json!({
8754                    "provider_type": "openai",
8755                    "enabled": true
8756                }))
8757                .unwrap(),
8758            );
8759        }
8760
8761        config.apply_runtime_env_overrides();
8762        assert_eq!(
8763            config.default_provider_instance.as_deref(),
8764            Some("personal")
8765        );
8766    }
8767
8768    #[test]
8769    fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
8770        let mut config = Config::default();
8771        config.features.provider_model_ref = false;
8772        config.provider = "openai".to_string();
8773        config.providers.openai = Some(OpenAIConfig {
8774            api_key: "test".to_string(),
8775            api_key_encrypted: None,
8776            credential_ref: None,
8777            base_url: None,
8778            model: Some("gpt-main".to_string()),
8779            fast_model: None,
8780            vision_model: None,
8781            reasoning_effort: None,
8782            responses_only_models: vec![],
8783            request_overrides: None,
8784            extra: BTreeMap::new(),
8785            api_key_from_env: false,
8786        });
8787
8788        assert_eq!(
8789            config.get_memory_background_model().as_deref(),
8790            Some("gpt-main")
8791        );
8792    }
8793
8794    #[test]
8795    fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
8796        let legacy = serde_json::json!({
8797            "memory": MemoryConfig {
8798                background_model: Some("dream-fast".to_string()),
8799                summary_target_ratio: 0.20,
8800                summary_safe_window_percent: 80,
8801                auto_dream_enabled: true,
8802                auto_dream_interval_secs: 900,
8803                project_prompt_injection: false,
8804                relevant_recall: false,
8805                relevant_recall_rerank: true,
8806                project_first_dream: false,
8807                ledger_agenda_injection: false,
8808                ledger_gardener_enabled: false,
8809                ledger_gardener_interval_secs: 7_200,
8810                ledger_distillation_enabled: false,
8811                dream_refine_mode: true,
8812                gardener_enabled: true,
8813                gardener_interval_secs: 3_600,
8814                gardener_volume_trigger: 40,
8815                gardener_max_splits_per_run: 4,
8816                gardener_min_sections: 7,
8817                dedup_gardener_enabled: true,
8818                dedup_gardener_min_score: 0.7,
8819                dedup_gardener_max_merges_per_run: 3,
8820                memory_active_capacity: 500,
8821                capacity_max_archivals_per_run: 10,
8822                granularity_freshness_gardener_enabled: false,
8823            }
8824        });
8825        let config: Config = serde_json::from_value(legacy).unwrap();
8826
8827        let serialized = serde_json::to_value(&config).expect("config should serialize");
8828        assert!(serialized.get("memory").is_some());
8829        let round_tripped: Config = serde_json::from_value(serialized).unwrap();
8830        assert!(round_tripped
8831            .memory()
8832            .as_ref()
8833            .is_some_and(|memory| memory.dream_refine_mode));
8834        let memory = config.memory.as_ref().expect("memory config should exist");
8835        assert!(memory.auto_dream_enabled);
8836        assert!(!memory.project_prompt_injection);
8837        assert!(!memory.relevant_recall);
8838        assert!(memory.relevant_recall_rerank);
8839        assert!(!memory.project_first_dream);
8840        assert!(memory.dream_refine_mode);
8841        assert!(memory.gardener_enabled);
8842        assert_eq!(memory.gardener_interval_secs, 3_600);
8843        assert_eq!(memory.gardener_volume_trigger, 40);
8844        assert_eq!(memory.gardener_max_splits_per_run, 4);
8845        assert_eq!(memory.gardener_min_sections, 7);
8846        assert!(memory.dedup_gardener_enabled);
8847        assert_eq!(memory.dedup_gardener_min_score, 0.7);
8848        assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
8849        assert_eq!(memory.memory_active_capacity, 500);
8850        assert_eq!(memory.capacity_max_archivals_per_run, 10);
8851        assert!(!memory.granularity_freshness_gardener_enabled);
8852    }
8853
8854    /// L5: capacity is OFF by default (0 = unbounded) — an opt-in feature.
8855    #[test]
8856    fn memory_active_capacity_defaults_off() {
8857        assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
8858        assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
8859        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8860        let memory = parsed.memory.as_ref().unwrap();
8861        assert_eq!(memory.memory_active_capacity, 0);
8862        assert_eq!(
8863            memory.capacity_max_archivals_per_run, 50,
8864            "omitted field takes the serde default fn"
8865        );
8866    }
8867
8868    #[test]
8869    fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
8870        let defaults = MemoryConfig::default();
8871        assert_eq!(defaults.summary_target_ratio, 0.20);
8872        assert_eq!(defaults.summary_safe_window_percent, 80);
8873
8874        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8875        let memory = parsed.memory.as_ref().expect("memory present");
8876        assert_eq!(memory.summary_target_ratio, 0.20);
8877        assert_eq!(memory.summary_safe_window_percent, 80);
8878    }
8879
8880    /// L4: the maintenance integrators are ON by default — both via
8881    /// `MemoryConfig::default()` AND when a config file omits the flags entirely
8882    /// (serde `default = fn`, not the bare `#[serde(default)]` = `false`).
8883    #[test]
8884    fn memory_maintenance_integrators_default_on() {
8885        let defaults = MemoryConfig::default();
8886        assert!(defaults.auto_dream_enabled);
8887        assert!(defaults.gardener_enabled);
8888        assert!(defaults.dedup_gardener_enabled);
8889        assert_eq!(defaults.gardener_volume_trigger, 25);
8890
8891        // A config that mentions `memory` but omits the flags must still be ON.
8892        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
8893        let memory = parsed.memory.as_ref().expect("memory present");
8894        assert!(
8895            memory.auto_dream_enabled,
8896            "auto_dream on when field omitted"
8897        );
8898        assert!(memory.gardener_enabled, "gardener on when field omitted");
8899        assert!(
8900            memory.dedup_gardener_enabled,
8901            "dedup gardener on when field omitted"
8902        );
8903        // An explicit opt-out is still honored.
8904        let opted_out: Config =
8905            serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
8906        assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
8907    }
8908
8909    #[test]
8910    fn memory_config_env_overrides_prompt_flags() {
8911        let _lock = env_lock_acquire();
8912        let temp_home = TempHome::new();
8913        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8914        let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
8915        let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
8916        let _relevant_recall_rerank =
8917            EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
8918        let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
8919
8920        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8921        let memory = config
8922            .memory
8923            .as_ref()
8924            .expect("memory config should be created by env overrides");
8925        assert!(!memory.project_prompt_injection);
8926        assert!(!memory.relevant_recall);
8927        assert!(memory.relevant_recall_rerank);
8928        assert!(!memory.project_first_dream);
8929    }
8930
8931    #[test]
8932    fn provider_api_keys_injected_from_env_and_never_persisted() {
8933        let _lock = env_lock_acquire();
8934        let temp_home = TempHome::new();
8935        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
8936        let _anthropic = crate::test_support::override_runtime_env_var(
8937            "BAMBOO_ANTHROPIC_API_KEY",
8938            Some("sk-ant-from-env"),
8939        );
8940        let _openai = crate::test_support::override_runtime_env_var(
8941            "BAMBOO_OPENAI_API_KEY",
8942            Some("sk-oai-from-env"),
8943        );
8944        let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8945
8946        // No config.json on disk → the providers are created from the env keys
8947        // alone (#253: deploy without a plaintext api_key in a mounted file).
8948        let config = Config::from_data_dir(Some(temp_home.path.clone()));
8949        assert_eq!(
8950            config
8951                .providers
8952                .anthropic
8953                .as_ref()
8954                .expect("anthropic created from env")
8955                .api_key,
8956            "sk-ant-from-env"
8957        );
8958        assert_eq!(
8959            config
8960                .providers
8961                .openai
8962                .as_ref()
8963                .expect("openai created from env")
8964                .api_key,
8965            "sk-oai-from-env"
8966        );
8967        // An unset provider is not fabricated.
8968        assert!(config.providers.gemini.is_none());
8969
8970        // The real "never persisted" guarantee: saving the config must NOT bake
8971        // the env key into config.json — not as plaintext AND not re-encrypted
8972        // into `api_key_encrypted` (which save's `refresh_provider_api_keys_encrypted`
8973        // would otherwise do). This is what actually happens on the server when
8974        // any unrelated setting is saved / on a fabric-reconcile boot.
8975        config
8976            .save_to_dir(temp_home.path.clone())
8977            .expect("save config");
8978        let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
8979            .expect("read persisted config.json");
8980        assert!(
8981            !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
8982            "env key must not be persisted as plaintext"
8983        );
8984        let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
8985        assert!(
8986            disk_json["providers"]["anthropic"]
8987                .get("api_key_encrypted")
8988                .is_none(),
8989            "env-sourced anthropic key must not be re-encrypted into config.json"
8990        );
8991        assert!(
8992            disk_json["providers"]["openai"]
8993                .get("api_key_encrypted")
8994                .is_none(),
8995            "env-sourced openai key must not be re-encrypted into config.json"
8996        );
8997
8998        // And once the env vars are gone, a reload from that same dir has no key
8999        // (nothing was persisted).
9000        drop(_anthropic);
9001        drop(_openai);
9002        let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
9003        assert!(reloaded
9004            .providers
9005            .anthropic
9006            .as_ref()
9007            .map(|a| a.api_key.is_empty())
9008            .unwrap_or(true));
9009    }
9010
9011    #[test]
9012    fn get_default_work_area_path_expands_tilde_and_requires_directory() {
9013        let _lock = env_lock_acquire();
9014        let temp_home = TempHome::new();
9015        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9016        let target = temp_home.path.join("workspace-default");
9017        std::fs::create_dir_all(&target).expect("default work area dir should exist");
9018
9019        let mut config = Config::default();
9020        config.default_work_area.replace(DefaultWorkAreaConfig {
9021            path: Some("~/workspace-default".to_string()),
9022        });
9023
9024        assert_eq!(config.get_default_work_area_path(), Some(target));
9025    }
9026
9027    #[test]
9028    fn get_default_work_area_path_returns_none_for_missing_directory() {
9029        let _lock = env_lock_acquire();
9030        let temp_home = TempHome::new();
9031        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9032
9033        let mut config = Config::default();
9034        config.default_work_area.replace(DefaultWorkAreaConfig {
9035            path: Some("~/missing-default-work-area".to_string()),
9036        });
9037
9038        assert!(config.get_default_work_area_path().is_none());
9039    }
9040
9041    #[test]
9042    fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9043        let mut config = Config::default();
9044        config.tools.disabled = vec![
9045            "  read_file  ".to_string(),
9046            "".to_string(),
9047            "read_file".to_string(),
9048            "bash".to_string(),
9049            "default::getCurrentDir".to_string(),
9050            "default::applyPatch".to_string(),
9051            "default::custom_tool".to_string(),
9052            "mcp__alpha__inspect".to_string(),
9053        ];
9054
9055        config.normalize_tool_settings();
9056
9057        assert_eq!(
9058            config.tools.disabled,
9059            vec![
9060                "bash",
9061                "default::applyPatch",
9062                "default::custom_tool",
9063                "default::getCurrentDir",
9064                "mcp__alpha__inspect",
9065                "read_file"
9066            ]
9067        );
9068    }
9069
9070    #[test]
9071    fn config_load_preserves_disabled_references_for_catalog_resolution() {
9072        let _lock = env_lock_acquire();
9073        let temp_home = TempHome::new();
9074        temp_home.set_config_json(
9075            r#"{
9076  "tools": {
9077    "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9078  }
9079}"#,
9080        );
9081
9082        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9083        assert_eq!(
9084            config.tools.disabled,
9085            vec!["bash", "default::getCurrentDir", "read_file"]
9086        );
9087        assert!(config.disabled_tool_references().contains("bash"));
9088        assert!(config.disabled_tool_references().contains("read_file"));
9089        assert!(config
9090            .disabled_tool_references()
9091            .contains("default::getCurrentDir"));
9092        assert_eq!(
9093            config.disabled_tool_names(),
9094            BTreeSet::from([
9095                "Bash".to_string(),
9096                "GetCurrentDir".to_string(),
9097                "Read".to_string()
9098            ])
9099        );
9100    }
9101
9102    #[test]
9103    fn normalize_skill_settings_trims_dedupes_and_sorts() {
9104        let mut config = Config::default();
9105        config.skills.disabled = vec![
9106            " pdf ".to_string(),
9107            "".to_string(),
9108            "pdf".to_string(),
9109            "skill-creator".to_string(),
9110        ];
9111
9112        config.normalize_skill_settings();
9113
9114        assert_eq!(
9115            config.skills.disabled,
9116            vec!["pdf".to_string(), "skill-creator".to_string()]
9117        );
9118    }
9119
9120    #[test]
9121    fn config_load_reads_disabled_skills_as_normalized_ids() {
9122        let _lock = env_lock_acquire();
9123        let temp_home = TempHome::new();
9124        temp_home.set_config_json(
9125            r#"{
9126  "skills": {
9127    "disabled": [" pdf ", "skill-creator", "pdf", ""]
9128  }
9129}"#,
9130        );
9131
9132        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9133        assert_eq!(
9134            config.skills.disabled,
9135            vec!["pdf".to_string(), "skill-creator".to_string()]
9136        );
9137        assert!(config.disabled_skill_ids().contains("pdf"));
9138        assert!(config.disabled_skill_ids().contains("skill-creator"));
9139    }
9140
9141    #[test]
9142    fn test_server_config_defaults() {
9143        let _lock = env_lock_acquire();
9144        let temp_home = TempHome::new();
9145
9146        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9147        assert_eq!(config.server.port, 9562);
9148        assert_eq!(config.server.bind, "127.0.0.1");
9149        assert_eq!(config.server.workers, 10);
9150        assert!(config.server.static_dir.is_none());
9151    }
9152
9153    #[test]
9154    fn test_server_addr() {
9155        let mut config = Config::default();
9156        config.server.port = 9000;
9157        config.server.bind = "0.0.0.0".to_string();
9158        assert_eq!(config.server_addr(), "0.0.0.0:9000");
9159    }
9160
9161    #[test]
9162    fn test_env_var_overrides() {
9163        let _lock = env_lock_acquire();
9164        let temp_home = TempHome::new();
9165
9166        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9167        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9168        let _provider =
9169            crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9170
9171        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9172        assert_eq!(config.server.port, 9999);
9173        assert_eq!(config.server.bind, "192.168.1.1");
9174        assert_eq!(config.provider, "openai");
9175    }
9176
9177    #[test]
9178    fn test_config_save_and_load() {
9179        let _lock = env_lock_acquire();
9180        let temp_home = TempHome::new();
9181
9182        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9183        config.server.port = 9000;
9184        config.server.bind = "0.0.0.0".to_string();
9185        config.provider = "anthropic".to_string();
9186
9187        // Save
9188        config
9189            .save_to_dir(temp_home.path.clone())
9190            .expect("Failed to save config");
9191
9192        // Load again
9193        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9194
9195        // Verify
9196        assert_eq!(loaded.server.port, 9000);
9197        assert_eq!(loaded.server.bind, "0.0.0.0");
9198        assert_eq!(loaded.provider, "anthropic");
9199    }
9200
9201    #[test]
9202    fn modular_root_and_public_serde_preserve_legacy_shape() {
9203        let _lock = env_lock_acquire();
9204        let temp_home = TempHome::new();
9205        let input = serde_json::json!({
9206            "http_proxy": "http://proxy.example",
9207            "provider": "openai",
9208            "mcp": { "servers": [] },
9209            "future_extension": { "enabled": true },
9210            "memory": { "background_model": "memory-model" },
9211            "subagents": { "max_concurrent": 7 },
9212            "providers": { "openai": { "model": "chat-model" } }
9213        });
9214
9215        let config: Config = serde_json::from_value(input).unwrap();
9216        let public = serde_json::to_value(&config).unwrap();
9217        assert_eq!(public["http_proxy"], "http://proxy.example");
9218        assert!(public.get("mcp").is_none());
9219        assert!(public.get("mcpServers").is_some());
9220        assert_eq!(public["future_extension"]["enabled"], true);
9221        assert_eq!(public["memory"]["background_model"], "memory-model");
9222        assert_eq!(public["subagents"]["max_concurrent"], 7);
9223        assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9224
9225        let round_tripped: Config = serde_json::from_value(public).unwrap();
9226        assert_eq!(
9227            round_tripped
9228                .memory()
9229                .as_ref()
9230                .unwrap()
9231                .background_model
9232                .as_deref(),
9233            Some("memory-model")
9234        );
9235        assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9236        assert_eq!(
9237            round_tripped
9238                .providers()
9239                .openai
9240                .as_ref()
9241                .unwrap()
9242                .model
9243                .as_deref(),
9244            Some("chat-model")
9245        );
9246        assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9247
9248        round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9249        let persisted: Value =
9250            serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9251                .unwrap();
9252        assert_eq!(persisted["http_proxy"], "http://proxy.example");
9253        assert!(persisted.get("mcpServers").is_some());
9254        assert_eq!(persisted["future_extension"]["enabled"], true);
9255        assert!(persisted.get("memory").is_none());
9256        assert!(persisted.get("subagents").is_none());
9257        assert!(persisted.get("providers").is_none());
9258        for internal_section_name in [
9259            "network",
9260            "provider_routing",
9261            "model_behavior",
9262            "tooling",
9263            "workspace",
9264            "execution",
9265            "integrations",
9266            "plugin_security",
9267        ] {
9268            assert!(persisted.get(internal_section_name).is_none());
9269        }
9270    }
9271
9272    #[test]
9273    fn config_decrypts_proxy_auth_from_encrypted_field() {
9274        let _lock = env_lock_acquire();
9275        let temp_home = TempHome::new();
9276
9277        // Use a stable encryption key so this test doesn't depend on host identifiers.
9278        let key_guard = crate::encryption::set_test_encryption_key([
9279            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9280            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9281            0x1c, 0x1d, 0x1e, 0x1f,
9282        ]);
9283
9284        let auth = ProxyAuth {
9285            username: "user".to_string(),
9286            password: "pass".to_string(),
9287        };
9288        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9289        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9290
9291        temp_home.set_config_json(&format!(
9292            r#"{{
9293  "http_proxy": "http://proxy.example.com:8080",
9294  "proxy_auth_encrypted": "{encrypted}"
9295}}"#
9296        ));
9297        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9298        let loaded_auth = config
9299            .proxy_auth
9300            .as_ref()
9301            .expect("proxy auth should be hydrated");
9302        assert_eq!(loaded_auth.username, "user");
9303        assert_eq!(loaded_auth.password, "pass");
9304        drop(key_guard);
9305    }
9306
9307    #[test]
9308    fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9309        let _lock = env_lock_acquire();
9310        let temp_home = TempHome::new();
9311
9312        // Use a stable encryption key so this test doesn't depend on host identifiers.
9313        let key_guard = crate::encryption::set_test_encryption_key([
9314            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9315            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9316            0x1c, 0x1d, 0x1e, 0x1f,
9317        ]);
9318
9319        let auth = ProxyAuth {
9320            username: "user".to_string(),
9321            password: "pass".to_string(),
9322        };
9323        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9324        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9325
9326        // Simulate older Bodhi/Tauri persisted config keys.
9327        temp_home.set_config_json(&format!(
9328            r#"{{
9329  "http_proxy": "http://proxy.example.com:8080",
9330  "http_proxy_auth_encrypted": "{encrypted}",
9331  "https_proxy_auth_encrypted": "{encrypted}"
9332}}"#
9333        ));
9334
9335        let config = Config::from_data_dir(Some(temp_home.path.clone()));
9336        let loaded_auth = config
9337            .proxy_auth
9338            .as_ref()
9339            .expect("proxy auth should be hydrated");
9340        assert_eq!(loaded_auth.username, "user");
9341        assert_eq!(loaded_auth.password, "pass");
9342        drop(key_guard);
9343    }
9344
9345    #[test]
9346    fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9347        let _lock = env_lock_acquire();
9348        let temp_home = TempHome::new();
9349
9350        // Use a stable encryption key so this test doesn't depend on host identifiers.
9351        let key_guard = crate::encryption::set_test_encryption_key([
9352            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9353            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9354            0x1c, 0x1d, 0x1e, 0x1f,
9355        ]);
9356
9357        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9358        config.proxy_auth = Some(ProxyAuth {
9359            username: "user".to_string(),
9360            password: "pass".to_string(),
9361        });
9362        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9363        assert!(error
9364            .to_string()
9365            .contains("isolated credential transaction"));
9366        let path = temp_home.path.join("config.json");
9367        assert!(
9368            !path.exists(),
9369            "a rejected unisolated secret must not create config.json"
9370        );
9371        drop(key_guard);
9372    }
9373
9374    #[test]
9375    fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9376        let _lock = env_lock_acquire();
9377        let temp_home = TempHome::new();
9378        let mut config = Config::default();
9379        config.env_vars.push(EnvVarEntry {
9380            name: "TOKEN".to_string(),
9381            value: String::new(),
9382            secret: true,
9383            value_encrypted: None,
9384            credential_ref: None,
9385            configured: true,
9386            description: None,
9387        });
9388        let path = temp_home.path.join("config.json");
9389
9390        let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9391        assert!(error
9392            .to_string()
9393            .contains("isolated credential transaction"));
9394        assert!(
9395            !path.exists(),
9396            "a rejected dangling ref must not create config.json"
9397        );
9398
9399        let original = br#"{"preserve":"original"}"#;
9400        std::fs::write(&path, original).unwrap();
9401        config.save_to_dir(temp_home.path.clone()).unwrap_err();
9402        assert_eq!(
9403            std::fs::read(&path).unwrap(),
9404            original,
9405            "a rejected dangling ref must not modify an existing config.json"
9406        );
9407    }
9408
9409    #[test]
9410    fn config_save_persists_provider_reference_and_isolates_plaintext() {
9411        let _lock = env_lock_acquire();
9412        let temp_home = TempHome::new();
9413
9414        // Use a stable encryption key so this test doesn't depend on host identifiers.
9415        let key_guard = crate::encryption::set_test_encryption_key([
9416            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9417            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9418            0x1c, 0x1d, 0x1e, 0x1f,
9419        ]);
9420
9421        let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9422        crate::CredentialStore::open(&temp_home.path)
9423            .replace(
9424                reference.clone(),
9425                "sk-test-provider-key",
9426                crate::CredentialSource::User,
9427                0,
9428            )
9429            .unwrap();
9430        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9431        config.provider = "openai".to_string();
9432        config.providers.openai = Some(OpenAIConfig {
9433            api_key: "sk-test-provider-key".to_string(),
9434            api_key_encrypted: None,
9435            credential_ref: Some(reference),
9436            base_url: None,
9437            model: None,
9438            fast_model: None,
9439            vision_model: None,
9440            reasoning_effort: None,
9441            responses_only_models: vec![],
9442            request_overrides: None,
9443            extra: Default::default(),
9444            api_key_from_env: false,
9445        });
9446
9447        config
9448            .save_to_dir(temp_home.path.clone())
9449            .expect("save should persist provider reference");
9450
9451        let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9452            .expect("read providers.json");
9453        assert!(
9454            !content.contains("api_key_encrypted"),
9455            "providers.json must not store provider ciphertext"
9456        );
9457        assert!(
9458            !content.contains("\"api_key\""),
9459            "providers.json should not store plaintext provider keys"
9460        );
9461
9462        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9463        let openai = loaded
9464            .providers
9465            .openai
9466            .as_ref()
9467            .expect("openai config should be present");
9468        assert_eq!(openai.api_key, "sk-test-provider-key");
9469
9470        drop(key_guard);
9471    }
9472
9473    #[test]
9474    fn config_save_persists_mcp_servers_in_mainstream_format() {
9475        let _lock = env_lock_acquire();
9476        let temp_home = TempHome::new();
9477
9478        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9479
9480        let mut env = std::collections::HashMap::new();
9481        env.insert("TOKEN".to_string(), "supersecret".to_string());
9482
9483        config.mcp.servers = vec![
9484            bamboo_domain::mcp_config::McpServerConfig {
9485                id: "stdio-secret".to_string(),
9486                name: None,
9487                enabled: true,
9488                transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9489                    bamboo_domain::mcp_config::StdioConfig {
9490                        command: "echo".to_string(),
9491                        args: vec![],
9492                        cwd: None,
9493                        env,
9494                        env_encrypted: std::collections::HashMap::new(),
9495                        env_credential_refs: std::collections::HashMap::new(),
9496                        startup_timeout_ms: 5000,
9497                    },
9498                ),
9499                request_timeout_ms: 5000,
9500                healthcheck_interval_ms: 1000,
9501                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9502                allowed_tools: vec![],
9503                denied_tools: vec![],
9504            },
9505            bamboo_domain::mcp_config::McpServerConfig {
9506                id: "http-secret".to_string(),
9507                name: None,
9508                enabled: true,
9509                transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9510                    bamboo_domain::mcp_config::StreamableHttpConfig {
9511                        url: "http://localhost:8080/http".to_string(),
9512                        headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9513                            name: "Authorization".to_string(),
9514                            value: "Bearer token123".to_string(),
9515                            value_encrypted: None,
9516                            credential_ref: None,
9517                        }],
9518                        connect_timeout_ms: 5000,
9519                    },
9520                ),
9521                request_timeout_ms: 5000,
9522                healthcheck_interval_ms: 1000,
9523                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9524                allowed_tools: vec![],
9525                denied_tools: vec![],
9526            },
9527        ];
9528
9529        config
9530            .save_to_dir(temp_home.path.clone())
9531            .expect("save should persist MCP servers");
9532
9533        let content =
9534            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9535        assert!(
9536            content.contains("\"mcpServers\""),
9537            "config.json should store MCP servers under the mainstream 'mcpServers' key"
9538        );
9539        assert!(
9540            content.contains("supersecret"),
9541            "config.json should persist MCP stdio env in mainstream format"
9542        );
9543        assert!(
9544            content.contains("Bearer token123"),
9545            "config.json should persist MCP Streamable HTTP headers in mainstream format"
9546        );
9547        assert!(
9548            !content.contains("\"env_encrypted\""),
9549            "config.json should not persist legacy env_encrypted fields"
9550        );
9551        assert!(
9552            !content.contains("\"value_encrypted\""),
9553            "config.json should not persist legacy value_encrypted fields"
9554        );
9555
9556        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9557        let stdio = loaded
9558            .mcp
9559            .servers
9560            .iter()
9561            .find(|s| s.id == "stdio-secret")
9562            .expect("stdio server should exist");
9563        match &stdio.transport {
9564            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9565                assert_eq!(
9566                    stdio.env.get("TOKEN").map(|s| s.as_str()),
9567                    Some("supersecret")
9568                );
9569            }
9570            _ => panic!("Expected stdio transport"),
9571        }
9572
9573        let http = loaded
9574            .mcp
9575            .servers
9576            .iter()
9577            .find(|s| s.id == "http-secret")
9578            .expect("http server should exist");
9579        match &http.transport {
9580            bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9581                assert_eq!(http.headers[0].value, "Bearer token123");
9582            }
9583            _ => panic!("Expected Streamable HTTP transport"),
9584        }
9585    }
9586
9587    #[test]
9588    fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9589        let _lock = env_lock_acquire();
9590        let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9591        let temp_home = TempHome::new();
9592        let env_plaintext = "mcp-root-env-plaintext-597";
9593        let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9594        let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9595        std::fs::write(
9596            temp_home.path.join("config.json"),
9597            serde_json::to_vec_pretty(&serde_json::json!({
9598                "features": {"provider_model_ref": true},
9599                "mcpServers": {
9600                    "stdio-root": {
9601                        "command": "unused-disabled-command",
9602                        "env": {"TOKEN": env_plaintext},
9603                        "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9604                        "env_credential_refs": {
9605                            "TOKEN": "mcp.stdio-root.env_TOKEN"
9606                        }
9607                    },
9608                    "http-root": {
9609                        "url": "https://example.test/mcp",
9610                        "transport_kind": "streamable_http",
9611                        "headers": {"Authorization": header_plaintext},
9612                        "header_credential_refs": {
9613                            "Authorization": "mcp.http-root.header_Authorization"
9614                        }
9615                    }
9616                }
9617            }))
9618            .unwrap(),
9619        )
9620        .unwrap();
9621        std::fs::write(
9622            temp_home.path.join("mcp.json"),
9623            serde_json::to_vec_pretty(&serde_json::json!({
9624                "schema_version": 1,
9625                "revision": 7,
9626                "data": {
9627                    "stdio-root": {
9628                        "command": "unused-disabled-command",
9629                        "enabled": false,
9630                        "env_encrypted": {"TOKEN": legacy_ciphertext},
9631                        "request_timeout_ms": 100,
9632                        "healthcheck_interval_ms": 100
9633                    },
9634                    "http-root": {
9635                        "url": "https://example.test/mcp",
9636                        "transport_kind": "streamable_http",
9637                        "enabled": false,
9638                        "headers": {"Authorization": header_plaintext},
9639                        "request_timeout_ms": 100,
9640                        "healthcheck_interval_ms": 100
9641                    }
9642                }
9643            }))
9644            .unwrap(),
9645        )
9646        .unwrap();
9647
9648        crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9649        let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9650            temp_home.path.join("mcp.json"),
9651            1,
9652        )
9653        .load()
9654        .unwrap()
9655        .unwrap();
9656        let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9657        config.mcp = stored.data;
9658        config
9659            .hydrate_mcp_credentials_from_store(&temp_home.path)
9660            .unwrap();
9661
9662        let public = serde_json::to_value(&config.mcp).unwrap();
9663        let rendered_public = public.to_string();
9664        assert!(rendered_public.contains(env_plaintext));
9665        assert!(rendered_public.contains(header_plaintext));
9666        let compatible: bamboo_domain::mcp_config::McpConfig =
9667            serde_json::from_value(public).unwrap();
9668        assert_eq!(compatible.servers.len(), 2);
9669
9670        config.features.provider_model_ref = !config.features.provider_model_ref;
9671        config.save_to_dir(temp_home.path.clone()).unwrap();
9672        config.features.provider_model_ref = !config.features.provider_model_ref;
9673        config.save_to_dir(temp_home.path.clone()).unwrap();
9674
9675        for entry in std::fs::read_dir(&temp_home.path)
9676            .unwrap()
9677            .filter_map(Result::ok)
9678        {
9679            let name = entry.file_name().to_string_lossy().to_string();
9680            if name == "credentials.json" || entry.path().is_dir() {
9681                continue;
9682            }
9683            if name == "config.json"
9684                || name == "mcp.json"
9685                || name.contains(".bak")
9686                || name.starts_with("config-credential-migration")
9687            {
9688                let bytes = std::fs::read(entry.path()).unwrap();
9689                let content = String::from_utf8_lossy(&bytes);
9690                assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9691                assert!(
9692                    !content.contains(header_plaintext),
9693                    "secret leaked to {name}"
9694                );
9695                assert!(
9696                    !content.contains(&legacy_ciphertext),
9697                    "legacy ciphertext leaked to {name}"
9698                );
9699            }
9700        }
9701    }
9702
9703    // ── Env vars lifecycle tests ──────────────────────────────
9704
9705    #[test]
9706    fn env_vars_as_map_includes_only_non_empty_values() {
9707        let mut config = Config::default();
9708        config.env_vars.extend([
9709            EnvVarEntry {
9710                name: "A".to_string(),
9711                value: "val_a".to_string(),
9712                secret: false,
9713                value_encrypted: None,
9714                credential_ref: None,
9715                configured: true,
9716                description: None,
9717            },
9718            EnvVarEntry {
9719                name: "B".to_string(),
9720                value: "".to_string(), // empty → should be excluded
9721                secret: true,
9722                value_encrypted: None,
9723                credential_ref: None,
9724                configured: false,
9725                description: None,
9726            },
9727            EnvVarEntry {
9728                name: "C".to_string(),
9729                value: "  ".to_string(), // whitespace-only → excluded
9730                secret: false,
9731                value_encrypted: None,
9732                credential_ref: None,
9733                configured: true,
9734                description: None,
9735            },
9736            EnvVarEntry {
9737                name: "D".to_string(),
9738                value: "val_d".to_string(),
9739                secret: true,
9740                value_encrypted: Some("enc".to_string()),
9741                credential_ref: None,
9742                configured: true,
9743                description: Some("desc".to_string()),
9744            },
9745        ]);
9746
9747        let map = config.env_vars_as_map();
9748        assert_eq!(map.len(), 2);
9749        assert_eq!(map.get("A"), Some(&"val_a".to_string()));
9750        assert_eq!(map.get("D"), Some(&"val_d".to_string()));
9751        assert!(!map.contains_key("B"));
9752        assert!(!map.contains_key("C"));
9753    }
9754
9755    #[test]
9756    fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
9757        let mut config = Config::default();
9758        config.env_vars.extend([
9759            EnvVarEntry {
9760                name: "PLAIN".to_string(),
9761                value: "visible".to_string(),
9762                secret: false,
9763                value_encrypted: None,
9764                credential_ref: None,
9765                configured: true,
9766                description: None,
9767            },
9768            EnvVarEntry {
9769                name: "SECRET".to_string(),
9770                value: "hidden_value".to_string(),
9771                secret: true,
9772                value_encrypted: Some("enc_data".to_string()),
9773                credential_ref: None,
9774                configured: true,
9775                description: None,
9776            },
9777        ]);
9778
9779        config.sanitize_env_vars_for_disk();
9780
9781        assert_eq!(config.env_vars[0].value, "visible"); // plain kept
9782        assert_eq!(config.env_vars[1].value, ""); // secret cleared
9783    }
9784
9785    #[test]
9786    fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
9787        let mut config = Config::default();
9788        config.env_vars.extend([
9789            EnvVarEntry {
9790                name: "OPEN".to_string(),
9791                value: "val".to_string(),
9792                secret: false,
9793                value_encrypted: None,
9794                credential_ref: None,
9795                configured: true,
9796                description: None,
9797            },
9798            EnvVarEntry {
9799                name: "HIDDEN".to_string(),
9800                value: "real_secret".to_string(),
9801                secret: true,
9802                value_encrypted: Some("enc".to_string()),
9803                credential_ref: None,
9804                configured: true,
9805                description: None,
9806            },
9807        ]);
9808
9809        config.sanitize_env_vars_for_disk();
9810
9811        // Plain value untouched
9812        assert_eq!(config.env_vars[0].value, "val");
9813        // Secret plaintext and legacy ciphertext are removed.
9814        assert_eq!(config.env_vars[1].value, "");
9815        assert!(config.env_vars[1].value_encrypted.is_none());
9816    }
9817
9818    #[test]
9819    fn legacy_env_ciphertext_is_read_but_never_serialized() {
9820        let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
9821        let mut config: Config = serde_json::from_value(serde_json::json!({
9822            "env_vars": [{
9823                "name": "TOKEN",
9824                "secret": true,
9825                "value_encrypted": ciphertext
9826            }]
9827        }))
9828        .unwrap();
9829        config.hydrate_env_vars_from_encrypted();
9830        assert_eq!(config.env_vars[0].value, "my-secret-token");
9831        let serialized = serde_json::to_value(&config).unwrap();
9832        assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
9833    }
9834
9835    #[test]
9836    fn configured_env_ref_missing_from_store_fails_closed() {
9837        let dir = tempfile::tempdir().unwrap();
9838        let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
9839        let mut config = Config::default();
9840        config.env_vars.push(EnvVarEntry {
9841            name: "TOKEN".to_string(),
9842            value: String::new(),
9843            secret: true,
9844            value_encrypted: None,
9845            credential_ref: Some(reference),
9846            configured: true,
9847            description: None,
9848        });
9849        let error = config
9850            .hydrate_env_var_credentials_from_store(dir.path())
9851            .unwrap_err();
9852        assert!(error
9853            .to_string()
9854            .contains("referenced env credential is unavailable"));
9855        assert!(config.env_vars[0].configured);
9856        assert!(config.env_vars[0].value.is_empty());
9857    }
9858
9859    #[test]
9860    fn configured_notification_ref_missing_from_store_fails_closed() {
9861        let dir = tempfile::tempdir().unwrap();
9862        let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
9863        let mut config = Config::default();
9864        config.notifications.ntfy.credential_ref = Some(reference);
9865        config.notifications.ntfy.configured = true;
9866
9867        let error = config
9868            .hydrate_notification_credentials_from_store(dir.path())
9869            .unwrap_err();
9870
9871        assert!(error
9872            .to_string()
9873            .contains("referenced ntfy credential is unavailable"));
9874        assert!(config.notifications.ntfy.configured);
9875        assert!(config.notifications.ntfy.token.is_none());
9876    }
9877
9878    #[test]
9879    fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
9880        let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
9881        let dir = tempfile::tempdir().unwrap();
9882        let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
9883        crate::CredentialStore::open(dir.path())
9884            .replace(
9885                reference.clone(),
9886                "exclusive-notification-secret",
9887                crate::CredentialSource::User,
9888                0,
9889            )
9890            .unwrap();
9891
9892        let mut config = Config::default();
9893        config.notifications.ntfy.credential_ref = Some(reference.clone());
9894        config.notifications.ntfy.configured = true;
9895        config.proxy_auth_credential_ref = Some(reference.clone());
9896        let error = config
9897            .hydrate_notification_credentials_from_store(dir.path())
9898            .unwrap_err();
9899        let rendered = error.to_string();
9900        assert!(rendered
9901            .contains("notification credential reference is shared by another config consumer"));
9902        assert!(!rendered.contains(reference.as_str()));
9903        assert!(config.notifications.ntfy.token.is_none());
9904
9905        config.proxy_auth_credential_ref = None;
9906        config
9907            .hydrate_notification_credentials_from_store(dir.path())
9908            .unwrap();
9909        assert_eq!(
9910            config.notifications.ntfy.token.as_deref(),
9911            Some("exclusive-notification-secret")
9912        );
9913    }
9914
9915    #[test]
9916    fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
9917        let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
9918        let dir = tempfile::tempdir().unwrap();
9919        let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
9920        let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
9921        let bytes = serde_json::to_vec_pretty(&serde_json::json!({
9922            "notifications": {
9923                "ntfy": { "enabled": true, "token_encrypted": ntfy },
9924                "bark": { "enabled": true, "device_key_encrypted": bark }
9925            }
9926        }))
9927        .unwrap();
9928        std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
9929        std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
9930
9931        let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
9932
9933        assert!(loaded.notifications.ntfy.token.is_none());
9934        assert!(loaded.notifications.bark.device_key.is_none());
9935        assert_eq!(
9936            std::fs::read(dir.path().join("config.json")).unwrap(),
9937            bytes
9938        );
9939    }
9940
9941    #[test]
9942    fn publish_and_current_env_vars_round_trip() {
9943        // `publish_env_vars` REPLACES the process-global env-vars cache
9944        // wholesale, so every test that touches that cache must hold the
9945        // crate-wide env lock. This test didn't (issue #486): running
9946        // concurrently with a lock-holding cache test (e.g.
9947        // `from_data_dir_without_publish_does_not_clobber_global_cache`) it
9948        // wiped that test's just-seeded marker out of the cache mid-assert —
9949        // and its own 10x retry loop below was itself a symptom of losing
9950        // the same race in the other direction. With the lock held, one
9951        // publish is deterministic.
9952        let _lock = crate::test_support::env_cache_lock_acquire();
9953        let mut config = Config::default();
9954        config.env_vars.extend([EnvVarEntry {
9955            name: "TEST_PUBLISH".to_string(),
9956            value: "pub_value".to_string(),
9957            secret: false,
9958            value_encrypted: None,
9959            credential_ref: None,
9960            configured: true,
9961            description: None,
9962        }]);
9963
9964        config.publish_env_vars();
9965        assert_eq!(
9966            Config::current_env_vars()
9967                .get("TEST_PUBLISH")
9968                .map(String::as_str),
9969            Some("pub_value")
9970        );
9971    }
9972
9973    #[test]
9974    fn broker_token_round_trips_encrypt_sanitize_hydrate() {
9975        let mut config = Config::default();
9976        config.subagents.broker = Some(BrokerClientConfig {
9977            endpoint: "ws://127.0.0.1:9600".to_string(),
9978            token: "super-secret-token".to_string(),
9979            token_encrypted: None,
9980            credential_ref: None,
9981            configured: false,
9982        });
9983
9984        // Persist path: encrypt then sanitize (what save_to_dir does).
9985        config.refresh_broker_token_encrypted().unwrap();
9986        config.sanitize_broker_token_for_disk();
9987        let broker = config.subagents.broker.as_ref().unwrap();
9988        assert!(broker.token.is_empty(), "plaintext cleared for disk");
9989        assert!(broker.token_encrypted.is_some(), "ciphertext stored");
9990        assert_ne!(
9991            broker.token_encrypted.as_deref(),
9992            Some("super-secret-token")
9993        );
9994
9995        // Load path: hydrate restores plaintext.
9996        config.hydrate_broker_token_from_encrypted();
9997        assert_eq!(
9998            config.subagents.broker.as_ref().unwrap().token,
9999            "super-secret-token"
10000        );
10001    }
10002
10003    #[test]
10004    fn broker_token_empty_refresh_preserves_ciphertext() {
10005        // A redacted round-trip (token empty) must not wipe the stored ciphertext.
10006        let mut config = Config::default();
10007        config.subagents.broker = Some(BrokerClientConfig {
10008            endpoint: "ws://h:9600".to_string(),
10009            token: String::new(),
10010            token_encrypted: Some("existing-cipher".to_string()),
10011            credential_ref: None,
10012            configured: false,
10013        });
10014        config.refresh_broker_token_encrypted().unwrap();
10015        assert_eq!(
10016            config
10017                .subagents
10018                .broker
10019                .as_ref()
10020                .unwrap()
10021                .token_encrypted
10022                .as_deref(),
10023            Some("existing-cipher"),
10024        );
10025    }
10026
10027    #[test]
10028    fn notifications_config_defaults_when_key_missing() {
10029        // Additive/back-compat: an absent `notifications` key must deserialize
10030        // to the built-in defaults (desktop auto, ntfy/bark disabled).
10031        let config: Config = serde_json::from_str("{}").expect("empty object parses");
10032        assert_eq!(config.notifications, NotificationsConfig::default());
10033        assert_eq!(config.notifications.desktop.enabled, None);
10034        assert!(!config.notifications.ntfy.enabled);
10035        assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10036        assert_eq!(config.notifications.ntfy.token, None);
10037        assert!(!config.notifications.bark.enabled);
10038        assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10039        assert_eq!(config.notifications.bark.device_key, None);
10040    }
10041
10042    #[test]
10043    fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10044        let mut config = Config::default();
10045        config.notifications.ntfy = NtfyChannelConfig {
10046            enabled: true,
10047            base_url: "https://ntfy.sh".to_string(),
10048            topic: "bamboo-alerts".to_string(),
10049            token: Some("tk_super_secret".to_string()),
10050            token_encrypted: None,
10051            credential_ref: None,
10052            configured: false,
10053        };
10054
10055        // Legacy compatibility path: retain a readable ciphertext until the
10056        // credential migration moves the secret into the isolated store.
10057        config.refresh_notifications_encrypted().unwrap();
10058        assert!(config.notifications.ntfy.token_encrypted.is_some());
10059        assert_ne!(
10060            config.notifications.ntfy.token_encrypted.as_deref(),
10061            Some("tk_super_secret")
10062        );
10063
10064        // Neither plaintext nor legacy ciphertext is serializable.
10065        let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10066        assert!(
10067            !json.contains("tk_super_secret"),
10068            "plaintext token must never be serialized"
10069        );
10070        assert!(!json.contains("token_encrypted"));
10071
10072        // Legacy load path restores plaintext for migration.
10073        config.notifications.ntfy.token = None;
10074        config.hydrate_notifications_from_encrypted();
10075        assert_eq!(
10076            config.notifications.ntfy.token.as_deref(),
10077            Some("tk_super_secret")
10078        );
10079    }
10080
10081    #[test]
10082    fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10083        let mut config = Config::default();
10084        config.notifications.bark = BarkChannelConfig {
10085            enabled: true,
10086            base_url: "https://api.day.app".to_string(),
10087            device_key: Some("dk_super_secret".to_string()),
10088            device_key_encrypted: None,
10089            credential_ref: None,
10090            configured: false,
10091        };
10092
10093        config.refresh_notifications_encrypted().unwrap();
10094        assert!(config.notifications.bark.device_key_encrypted.is_some());
10095        assert_ne!(
10096            config.notifications.bark.device_key_encrypted.as_deref(),
10097            Some("dk_super_secret")
10098        );
10099
10100        let json = serde_json::to_string(&config.notifications.bark).unwrap();
10101        assert!(
10102            !json.contains("dk_super_secret"),
10103            "plaintext device key must never be serialized"
10104        );
10105        assert!(!json.contains("device_key_encrypted"));
10106
10107        config.notifications.bark.device_key = None;
10108        config.hydrate_notifications_from_encrypted();
10109        assert_eq!(
10110            config.notifications.bark.device_key.as_deref(),
10111            Some("dk_super_secret")
10112        );
10113    }
10114
10115    #[test]
10116    fn notification_secrets_empty_refresh_preserves_ciphertext() {
10117        // The legacy compatibility helper retains already-loaded ciphertext;
10118        // ordinary persistence sanitizes it before writing.
10119        let mut config = Config::default();
10120        config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10121        config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10122
10123        config.refresh_notifications_encrypted().unwrap();
10124
10125        assert_eq!(
10126            config.notifications.ntfy.token_encrypted.as_deref(),
10127            Some("existing-ntfy-cipher")
10128        );
10129        assert_eq!(
10130            config.notifications.bark.device_key_encrypted.as_deref(),
10131            Some("existing-bark-cipher")
10132        );
10133    }
10134
10135    #[test]
10136    fn hydrate_skips_non_secret_entries() {
10137        let mut config = Config::default();
10138        config.env_vars.extend([EnvVarEntry {
10139            name: "PLAIN".to_string(),
10140            value: "original".to_string(),
10141            secret: false,
10142            value_encrypted: Some("should-be-ignored".to_string()),
10143            credential_ref: None,
10144            configured: true,
10145            description: None,
10146        }]);
10147
10148        config.hydrate_env_vars_from_encrypted();
10149        // Non-secret entry should keep its original value
10150        assert_eq!(config.env_vars[0].value, "original");
10151    }
10152
10153    #[test]
10154    fn default_config_has_empty_env_vars() {
10155        // `Config::default()` is a pure in-memory constructor (no disk read, no
10156        // env overrides), so this is independent of the developer's
10157        // `~/.bamboo/config.json` — no temp-dir isolation needed. Directly
10158        // asserts the #38 invariant that default() does not touch the filesystem.
10159        assert!(Config::default().env_vars.is_empty());
10160    }
10161
10162    #[test]
10163    fn serde_round_trip_with_env_vars() {
10164        let mut config = Config::default();
10165        config.env_vars.extend([
10166            EnvVarEntry {
10167                name: "KEY1".to_string(),
10168                value: "val1".to_string(),
10169                secret: false,
10170                value_encrypted: None,
10171                credential_ref: None,
10172                configured: true,
10173                description: Some("First key".to_string()),
10174            },
10175            EnvVarEntry {
10176                name: "KEY2".to_string(),
10177                value: "".to_string(), // on-disk secret has no plaintext
10178                secret: true,
10179                value_encrypted: Some("enc123".to_string()),
10180                credential_ref: None,
10181                configured: true,
10182                description: None,
10183            },
10184        ]);
10185
10186        let json = serde_json::to_string(&config).unwrap();
10187        let restored: Config = serde_json::from_str(&json).unwrap();
10188
10189        assert_eq!(restored.env_vars.len(), 2);
10190        assert_eq!(restored.env_vars[0].name, "KEY1");
10191        assert_eq!(restored.env_vars[0].value, "val1");
10192        assert!(!restored.env_vars[0].secret);
10193        assert_eq!(restored.env_vars[1].name, "KEY2");
10194        assert!(restored.env_vars[1].secret);
10195        assert!(restored.env_vars[1].value_encrypted.is_none());
10196    }
10197
10198    // ---- defaults.* model resolution tests ----
10199
10200    #[test]
10201    // fields set conditionally below
10202    #[allow(clippy::field_reassign_with_default)]
10203    fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10204        let mut config = Config::default();
10205        config.provider = "openai".to_string();
10206        config.providers.openai = Some(OpenAIConfig {
10207            api_key: "test".to_string(),
10208            api_key_encrypted: None,
10209            credential_ref: None,
10210            base_url: None,
10211            model: Some("legacy-gpt-4o".to_string()),
10212            fast_model: None,
10213            vision_model: None,
10214            reasoning_effort: None,
10215            responses_only_models: vec![],
10216            request_overrides: None,
10217            extra: Default::default(),
10218            api_key_from_env: false,
10219        });
10220        config.features.provider_model_ref = true;
10221        config.defaults = Some(DefaultsConfig {
10222            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10223            fast: None,
10224            task_summary: None,
10225            vision: None,
10226            memory_background: None,
10227            planning: None,
10228            search: None,
10229            code_review: None,
10230            sub_agent: None,
10231            subagent_models: Default::default(),
10232        });
10233
10234        assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10235    }
10236
10237    #[test]
10238    // fields set conditionally below
10239    #[allow(clippy::field_reassign_with_default)]
10240    fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10241        let mut config = Config::default();
10242        config.provider = "openai".to_string();
10243        config.providers.openai = Some(OpenAIConfig {
10244            api_key: "test".to_string(),
10245            api_key_encrypted: None,
10246            credential_ref: None,
10247            base_url: None,
10248            model: Some("legacy-gpt-4o".to_string()),
10249            fast_model: None,
10250            vision_model: None,
10251            reasoning_effort: None,
10252            responses_only_models: vec![],
10253            request_overrides: None,
10254            extra: Default::default(),
10255            api_key_from_env: false,
10256        });
10257        config.features.provider_model_ref = false;
10258        config.defaults = Some(DefaultsConfig {
10259            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10260            fast: None,
10261            task_summary: None,
10262            vision: None,
10263            memory_background: None,
10264            planning: None,
10265            search: None,
10266            code_review: None,
10267            sub_agent: None,
10268            subagent_models: Default::default(),
10269        });
10270
10271        assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10272    }
10273
10274    #[test]
10275    // fields set conditionally below
10276    #[allow(clippy::field_reassign_with_default)]
10277    fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10278        let mut config = Config::default();
10279        config.provider = "openai".to_string();
10280        config.providers.openai = Some(OpenAIConfig {
10281            api_key: "test".to_string(),
10282            api_key_encrypted: None,
10283            credential_ref: None,
10284            base_url: None,
10285            model: Some("gpt-4o".to_string()),
10286            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10287            vision_model: None,
10288            reasoning_effort: None,
10289            responses_only_models: vec![],
10290            request_overrides: None,
10291            extra: Default::default(),
10292            api_key_from_env: false,
10293        });
10294        config.features.provider_model_ref = true;
10295        config.defaults = Some(DefaultsConfig {
10296            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10297            fast: Some(bamboo_domain::ProviderModelRef::new(
10298                "anthropic",
10299                "claude-3-5-haiku",
10300            )),
10301            task_summary: None,
10302            vision: None,
10303            memory_background: None,
10304            planning: None,
10305            search: None,
10306            code_review: None,
10307            sub_agent: None,
10308            subagent_models: Default::default(),
10309        });
10310
10311        assert_eq!(
10312            config.get_fast_model(),
10313            Some("claude-3-5-haiku".to_string())
10314        );
10315    }
10316
10317    #[test]
10318    // fields set conditionally below
10319    #[allow(clippy::field_reassign_with_default)]
10320    fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10321        let mut config = Config::default();
10322        config.provider = "openai".to_string();
10323        config.providers.openai = Some(OpenAIConfig {
10324            api_key: "test".to_string(),
10325            api_key_encrypted: None,
10326            credential_ref: None,
10327            base_url: None,
10328            model: Some("gpt-4o".to_string()),
10329            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10330            vision_model: None,
10331            reasoning_effort: None,
10332            responses_only_models: vec![],
10333            request_overrides: None,
10334            extra: Default::default(),
10335            api_key_from_env: false,
10336        });
10337        config.features.provider_model_ref = false;
10338        config.defaults = Some(DefaultsConfig {
10339            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10340            fast: Some(bamboo_domain::ProviderModelRef::new(
10341                "anthropic",
10342                "claude-3-5-haiku",
10343            )),
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("legacy-gpt-4o-mini".to_string())
10357        );
10358    }
10359
10360    #[test]
10361    // fields set conditionally below
10362    #[allow(clippy::field_reassign_with_default)]
10363    fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10364        let mut config = Config::default();
10365        config.provider = "openai".to_string();
10366        config.features.provider_model_ref = true;
10367        config.defaults = Some(DefaultsConfig {
10368            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10369            fast: None,
10370            task_summary: None,
10371            vision: None,
10372            memory_background: None,
10373            planning: None,
10374            search: None,
10375            code_review: None,
10376            sub_agent: None,
10377            subagent_models: Default::default(),
10378        });
10379
10380        assert_eq!(
10381            config.get_fast_model(),
10382            Some("claude-3-7-sonnet".to_string())
10383        );
10384    }
10385
10386    #[test]
10387    // fields set conditionally below
10388    #[allow(clippy::field_reassign_with_default)]
10389    fn get_memory_background_model_prefers_defaults_memory_background() {
10390        let mut config = Config::default();
10391        config.provider = "openai".to_string();
10392        config.providers.openai = Some(OpenAIConfig {
10393            api_key: "test".to_string(),
10394            api_key_encrypted: None,
10395            credential_ref: None,
10396            base_url: None,
10397            model: Some("gpt-4o".to_string()),
10398            fast_model: Some("gpt-4o-mini".to_string()),
10399            vision_model: None,
10400            reasoning_effort: None,
10401            responses_only_models: vec![],
10402            request_overrides: None,
10403            extra: Default::default(),
10404            api_key_from_env: false,
10405        });
10406        config.features.provider_model_ref = true;
10407        config.defaults = Some(DefaultsConfig {
10408            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10409            fast: Some(bamboo_domain::ProviderModelRef::new(
10410                "openai",
10411                "gpt-4o-mini",
10412            )),
10413            task_summary: None,
10414            vision: None,
10415            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10416                "anthropic",
10417                "claude-3-5-haiku",
10418            )),
10419            planning: None,
10420            search: None,
10421            code_review: None,
10422            sub_agent: None,
10423            subagent_models: Default::default(),
10424        });
10425
10426        assert_eq!(
10427            config.get_memory_background_model(),
10428            Some("claude-3-5-haiku".to_string())
10429        );
10430    }
10431
10432    #[test]
10433    // fields set conditionally below
10434    #[allow(clippy::field_reassign_with_default)]
10435    fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10436        let mut config = Config::default();
10437        config.provider = "openai".to_string();
10438        config.features.provider_model_ref = true;
10439        config.defaults = Some(DefaultsConfig {
10440            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10441            fast: Some(bamboo_domain::ProviderModelRef::new(
10442                "anthropic",
10443                "claude-3-5-haiku",
10444            )),
10445            task_summary: None,
10446            vision: None,
10447            memory_background: None,
10448            planning: None,
10449            search: None,
10450            code_review: None,
10451            sub_agent: None,
10452            subagent_models: Default::default(),
10453        });
10454
10455        assert_eq!(
10456            config.get_memory_background_model(),
10457            Some("claude-3-5-haiku".to_string())
10458        );
10459    }
10460
10461    #[test]
10462    // fields set conditionally below
10463    #[allow(clippy::field_reassign_with_default)]
10464    fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10465        let mut config = Config::default();
10466        config.provider = "openai".to_string();
10467        config.providers.openai = Some(OpenAIConfig {
10468            api_key: "test".to_string(),
10469            api_key_encrypted: None,
10470            credential_ref: None,
10471            base_url: None,
10472            model: Some("gpt-4o".to_string()),
10473            fast_model: Some("legacy-gpt-4o-mini".to_string()),
10474            vision_model: None,
10475            reasoning_effort: None,
10476            responses_only_models: vec![],
10477            request_overrides: None,
10478            extra: Default::default(),
10479            api_key_from_env: false,
10480        });
10481        config.features.provider_model_ref = false;
10482        config.defaults = Some(DefaultsConfig {
10483            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10484            fast: Some(bamboo_domain::ProviderModelRef::new(
10485                "anthropic",
10486                "claude-3-5-haiku",
10487            )),
10488            task_summary: None,
10489            vision: None,
10490            memory_background: Some(bamboo_domain::ProviderModelRef::new(
10491                "anthropic",
10492                "claude-3-5-haiku",
10493            )),
10494            planning: None,
10495            search: None,
10496            code_review: None,
10497            sub_agent: None,
10498            subagent_models: Default::default(),
10499        });
10500
10501        assert_eq!(
10502            config.get_memory_background_model(),
10503            Some("legacy-gpt-4o-mini".to_string())
10504        );
10505    }
10506
10507    // -------------------------------------------------------------------
10508    // `is_host_trusted` — plugin source-trust host allowlist (component
10509    // matching, not raw string-prefix matching; see the function's own docs
10510    // for the bypasses this closes).
10511    // -------------------------------------------------------------------
10512
10513    #[test]
10514    fn is_host_trusted_requires_https_scheme() {
10515        let hosts = vec!["github.com/bigduu/".to_string()];
10516        assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10517        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10518    }
10519
10520    #[test]
10521    fn is_host_trusted_is_case_insensitive_on_both_sides() {
10522        // A lowercase URL host against a mixed-case config entry...
10523        let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10524        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10525        // ...and a mixed-case URL host against a lowercase config entry.
10526        let hosts = vec!["github.com/bigduu/".to_string()];
10527        assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10528    }
10529
10530    #[test]
10531    fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10532        let hosts = vec!["trusted.example.com".to_string()];
10533        assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10534        // Both demonstrated bypasses of a raw string-prefix match: gluing a
10535        // longer attacker-controlled label onto the trusted host, with or
10536        // without a separating dot.
10537        assert!(!is_host_trusted(
10538            "https://trusted.example.com.evil.com/x",
10539            &hosts
10540        ));
10541        assert!(!is_host_trusted(
10542            "https://trusted.example.comevil.com/x",
10543            &hosts
10544        ));
10545    }
10546
10547    #[test]
10548    fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10549        // No trailing slash on the config entry's path component.
10550        let hosts = vec!["github.com/bigduu/".to_string()];
10551        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10552        assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10553    }
10554
10555    #[test]
10556    fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10557        let hosts = vec!["example.com".to_string()];
10558        assert!(is_host_trusted("https://example.com/", &hosts));
10559        assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10560        // Still only that exact host — a bare-host entry must not become a
10561        // blanket "any host containing this string" match.
10562        assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10563        assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10564    }
10565
10566    #[test]
10567    fn is_host_trusted_uses_the_real_host_not_userinfo() {
10568        let hosts = vec!["github.com/bigduu/".to_string()];
10569        // `user@host` userinfo does not change the actual host.
10570        assert!(is_host_trusted(
10571            "https://someuser@github.com/bigduu/x",
10572            &hosts
10573        ));
10574        // A decoy host placed in the userinfo position must not be mistaken
10575        // for the real host — the real host here is `evil.com`.
10576        assert!(!is_host_trusted(
10577            "https://github.com@evil.com/bigduu/",
10578            &hosts
10579        ));
10580    }
10581
10582    #[test]
10583    fn is_host_trusted_ignores_an_explicit_port() {
10584        let hosts = vec!["github.com/bigduu/".to_string()];
10585        assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10586    }
10587
10588    #[test]
10589    fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10590        let hosts = vec!["github.com/bigduu/".to_string()];
10591        assert!(!is_host_trusted("not a url at all", &hosts));
10592        assert!(!is_host_trusted("", &hosts));
10593        assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); // no scheme
10594    }
10595
10596    #[test]
10597    fn is_host_trusted_normalizes_dot_segments_before_matching() {
10598        let hosts = vec!["github.com/bigduu/".to_string()];
10599        // `Url::parse` resolves `..` segments before `path()` is ever
10600        // consulted, so this cannot be used to escape the trusted prefix.
10601        assert!(!is_host_trusted(
10602            "https://github.com/bigduu/../evil/x",
10603            &hosts
10604        ));
10605        // A `..` that stays under the trusted prefix once resolved is fine.
10606        assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10607    }
10608
10609    #[test]
10610    fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10611        let mut config = Config::default();
10612        config.plugin_trust.trusted_hosts = vec![
10613            "  GitHub.com/BigDuu/ ".to_string(),
10614            "".to_string(),
10615            "   ".to_string(),
10616            "Example.COM".to_string(),
10617        ];
10618        config.normalize_plugin_trust_settings();
10619        assert_eq!(
10620            config.plugin_trust.trusted_hosts,
10621            vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10622        );
10623    }
10624
10625    // -----------------------------------------------------------------
10626    // `plugin_trust.enforcement` — the persistent, config-level form of the
10627    // `--insecure` escape hatch.
10628    // -----------------------------------------------------------------
10629
10630    #[test]
10631    fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10632        // A fresh `Config::default()` (nothing on disk at all).
10633        let config = Config::default();
10634        assert_eq!(
10635            config.plugin_trust.enforcement,
10636            PluginTrustEnforcement::Strict
10637        );
10638        assert!(!config.plugin_trust.enforcement_is_off());
10639
10640        // A `plugin_trust` object present in JSON but with NO `enforcement`
10641        // key at all (e.g. a config.json written before this field existed)
10642        // must ALSO deserialize to Strict, not fail or silently do something
10643        // else — additive/back-compat, matching `trusted_hosts`/
10644        // `trusted_keys`'s own `#[serde(default = ...)]` behavior.
10645        let json = serde_json::json!({
10646            "trusted_hosts": ["example.com"],
10647            "trusted_keys": [],
10648        });
10649        let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10650        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10651    }
10652
10653    #[test]
10654    fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10655        for raw in ["off", "OFF", "Off", " off "] {
10656            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10657                "enforcement": raw,
10658            }))
10659            .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10660            assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10661            assert!(trust.enforcement_is_off());
10662        }
10663        for raw in ["strict", "STRICT", " Strict "] {
10664            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10665                "enforcement": raw,
10666            }))
10667            .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10668            assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10669        }
10670
10671        let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10672            "enforcement": "nonsense",
10673        }))
10674        .expect_err("an unrecognized string must be rejected, not silently default");
10675        assert!(err.to_string().contains("nonsense"));
10676    }
10677
10678    #[test]
10679    fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10680        // A hand-edited config.json using a plain bool reads naturally: is
10681        // enforcement ON (`true`) or OFF (`false`)?
10682        let trust: PluginTrustConfig =
10683            serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10684        assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10685
10686        let trust: PluginTrustConfig =
10687            serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10688        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10689    }
10690
10691    #[test]
10692    fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10693        // Regardless of which accepted input form produced it, the
10694        // in-memory value always serializes back out as the canonical
10695        // string — this is what the dot-path `config set` setter's
10696        // round-trip check relies on (see `dot_path.rs`'s module docs).
10697        let trust = PluginTrustConfig {
10698            enforcement: PluginTrustEnforcement::Off,
10699            ..PluginTrustConfig::default()
10700        };
10701        let json = serde_json::to_value(&trust).unwrap();
10702        assert_eq!(json["enforcement"], "off");
10703
10704        let trust = PluginTrustConfig {
10705            enforcement: PluginTrustEnforcement::Strict,
10706            ..PluginTrustConfig::default()
10707        };
10708        let json = serde_json::to_value(&trust).unwrap();
10709        assert_eq!(json["enforcement"], "strict");
10710    }
10711
10712    #[test]
10713    fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
10714        // `normalize_plugin_trust_settings` only touches `trusted_hosts` —
10715        // confirm it's a true no-op on `enforcement` either way.
10716        let mut config = Config::default();
10717        config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
10718        config.normalize_plugin_trust_settings();
10719        assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
10720    }
10721
10722    #[test]
10723    fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
10724        // Confirms the dot-path `bamboo config set plugin_trust.enforcement
10725        // off` path actually works end to end through
10726        // `crate::dot_path::apply_dot_path_set` (the generic JSON-patch
10727        // setter), not just direct field assignment.
10728        let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
10729            "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
10730        )));
10731        assert_eq!(
10732            config.plugin_trust.enforcement,
10733            PluginTrustEnforcement::Strict
10734        );
10735
10736        let outcome = crate::dot_path::apply_dot_path_set(
10737            &config,
10738            "plugin_trust.enforcement",
10739            crate::dot_path::parse_cli_value("off"),
10740        )
10741        .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
10742        assert_eq!(
10743            outcome.config.plugin_trust.enforcement,
10744            PluginTrustEnforcement::Off
10745        );
10746
10747        // And back to strict.
10748        let outcome = crate::dot_path::apply_dot_path_set(
10749            &outcome.config,
10750            "plugin_trust.enforcement",
10751            crate::dot_path::parse_cli_value("strict"),
10752        )
10753        .expect("setting it back to strict should also round-trip");
10754        assert_eq!(
10755            outcome.config.plugin_trust.enforcement,
10756            PluginTrustEnforcement::Strict
10757        );
10758    }
10759}
10760
10761#[cfg(test)]
10762mod final_evidence_feature_tests {
10763    use super::FeatureFlags;
10764
10765    #[test]
10766    fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
10767        let existing: FeatureFlags =
10768            serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
10769        assert!(!existing.final_evidence_check);
10770        let enabled: FeatureFlags =
10771            serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
10772        let restored: FeatureFlags =
10773            serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
10774        assert!(restored.final_evidence_check);
10775        assert!(!FeatureFlags::default().final_evidence_check);
10776    }
10777}