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