Skip to main content

bamboo_config/
config.rs

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