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