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