Skip to main content

bamboo_config/
config.rs

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