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//! Configuration is stored in `config.json` under the unified data directory
10//! (defaults to `${HOME}/.bamboo/`). Environment variables can override file values.
11//!
12//! # Example (JSON)
13//!
14//! ```json
15//! {
16//!   "provider": "anthropic",
17//!   "server": {
18//!     "port": 9562,
19//!     "bind": "127.0.0.1"
20//!   },
21//!   "providers": {
22//!     "anthropic": {
23//!       "api_key": "sk-ant-...",
24//!       "model": "claude-3-5-sonnet-20241022"
25//!     },
26//!     "openai": {
27//!       "api_key": "sk-...",
28//!       "base_url": "https://api.openai.com/v1"
29//!     }
30//!   }
31//! }
32//! ```
33//!
34//! # Priority Order
35//!
36//! Configuration values are loaded in this order (later overrides earlier):
37//! 1. Code defaults (hardcoded default values)
38//! 2. Config file values (from `${HOME}/.bamboo/config.json`)
39//! 3. Environment variables (e.g., `BAMBOO_PORT`)
40//! 4. CLI arguments (e.g., `--port 9000`)
41//!
42//! # Environment Variables
43//!
44//! - `BAMBOO_DATA_DIR`: Override data directory location
45//! - `BAMBOO_PORT`: Override server port
46//! - `BAMBOO_BIND`: Override server bind address
47//! - `BAMBOO_PROVIDER`: Override default provider
48//! - `BAMBOO_HEADLESS`: Enable headless authentication mode
49//! - `BAMBOO_OPENAI_API_KEY` / `BAMBOO_ANTHROPIC_API_KEY` / `BAMBOO_GEMINI_API_KEY`:
50//!   Supply a provider's API key from the environment (in-memory only, never
51//!   persisted) — for 12-factor / secret-manager / CI deploys without a
52//!   plaintext key in config.json.
53
54use anyhow::{Context, Result};
55use bamboo_domain::poison::PoisonRecover;
56use serde::{Deserialize, Serialize};
57use serde_json::Value;
58use std::collections::{BTreeMap, BTreeSet, HashMap};
59use std::io::Write;
60use std::path::PathBuf;
61use std::sync::{OnceLock, RwLock};
62
63use crate::keyword_masking::KeywordMaskingConfig;
64use crate::model_mapping::{AnthropicModelMapping, GeminiModelMapping};
65use bamboo_domain::tool_names::normalize_tool_ref;
66use bamboo_domain::ReasoningEffort;
67
68/// A user-managed environment variable that is injected into Bash tool processes.
69///
70/// Secret entries are encrypted at rest: `value` is empty on disk and populated
71/// in memory after hydration from `value_encrypted`.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct EnvVarEntry {
74    /// Variable name (must match `^[A-Za-z_][A-Za-z0-9_]*$`).
75    pub name: String,
76    /// Plaintext value – populated in memory after hydration.
77    /// For `secret=true` entries this field is empty on disk.
78    #[serde(default)]
79    pub value: String,
80    /// Whether this variable contains sensitive data (token, password, etc.).
81    #[serde(default)]
82    pub secret: bool,
83    /// Encrypted ciphertext (only present on disk for secret entries).
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub value_encrypted: Option<String>,
86    /// Optional human-readable description.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub description: Option<String>,
89}
90
91/// Default work area configuration.
92///
93/// Allows Bamboo to operate without an explicit initial workspace while still
94/// providing a stable fallback directory for relative-path tool execution.
95#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
96pub struct DefaultWorkAreaConfig {
97    /// Optional default filesystem path used when a session has no active workspace.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub path: Option<String>,
100}
101
102/// Access control configuration for password-based UI/API gating.
103#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
104pub struct AccessControlConfig {
105    /// Whether password protection is enabled.
106    #[serde(default)]
107    pub password_enabled: bool,
108    /// Password hash (hex-encoded). Never expose via API.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub password_hash: Option<String>,
111    /// Salt used for hashing (hex-encoded). Never expose via API.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub password_salt: Option<String>,
114    /// Last update timestamp for auditing / debugging.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub updated_at: Option<String>,
117    /// v2 (#181): issued per-device tokens. Empty = root-password-only mode
118    /// (back-compat with old instances). Each entry stores only the token hash;
119    /// the plaintext token is returned to the client once at pairing time.
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub devices: Vec<DeviceCredential>,
122}
123
124/// A single paired device's credential (v2-P2 per-device token, #181).
125///
126/// The server stores only `token_hash` (never the plaintext token). The hash is
127/// computed with the SAME construction as the access password — `SHA-256(salt ||
128/// token)` — so no new crypto dependency is introduced (`docs/api-v2-transport.md`
129/// §4.2).
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub struct DeviceCredential {
132    /// Server-generated stable id: `bamboo_<12 hex>`.
133    pub device_id: String,
134    /// Human-readable label, e.g. "iPhone 15".
135    pub label: String,
136    /// `SHA-256(hex_decode(token_salt) || token)`, hex-encoded.
137    pub token_hash: String,
138    /// Per-device salt (hex-encoded).
139    pub token_salt: String,
140    /// RFC3339 creation timestamp.
141    pub created_at: String,
142    /// RFC3339 last-used timestamp (deferred stamping; see PR).
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub last_used_at: Option<String>,
145    /// Whether this device's token has been revoked. A revoked token is rejected
146    /// at the handshake/middleware immediately.
147    #[serde(default)]
148    pub revoked: bool,
149}
150
151/// Memory and background summarization configuration.
152// No `Eq`: `dedup_gardener_min_score` is an f64 (PartialEq only).
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct MemoryConfig {
155    /// Optional dedicated model for memory/session summarization and reflection.
156    /// Falls back to the provider fast model when unset.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub background_model: Option<String>,
159    /// Whether lightweight automatic Dream-style consolidation should run in the
160    /// background. Default ON (memory redesign L4): each tick no-ops when there is
161    /// no background model configured or no new candidate sessions, so it is free
162    /// until there is real work + a model. Set false to opt out.
163    #[serde(default = "default_true_auto_dream_enabled")]
164    pub auto_dream_enabled: bool,
165    /// Seconds between background auto-Dream ticks (default 30 minutes).
166    /// Each tick still no-ops when there are no new candidate sessions, so raising
167    /// this only lowers how often an active user triggers a real consolidation.
168    #[serde(default = "default_auto_dream_interval_secs")]
169    pub auto_dream_interval_secs: u64,
170    /// Whether project durable-memory index injection is enabled for the main prompt.
171    #[serde(
172        default = "default_true_memory_project_prompt_injection",
173        alias = "memory_project_prompt_injection"
174    )]
175    pub project_prompt_injection: bool,
176    /// Whether automatic relevant durable-memory recall is enabled for the main prompt.
177    #[serde(
178        default = "default_true_memory_relevant_recall",
179        alias = "memory_relevant_recall"
180    )]
181    pub relevant_recall: bool,
182    /// Whether relevant durable-memory recall should rerank lexical shortlist candidates
183    /// using the configured memory/background model.
184    #[serde(default, alias = "memory_relevant_recall_rerank")]
185    pub relevant_recall_rerank: bool,
186    /// Whether Dream prompt injection should prefer project Dream and only use global Dream as fallback.
187    #[serde(
188        default = "default_true_memory_project_first_dream",
189        alias = "memory_project_first_dream"
190    )]
191    pub project_first_dream: bool,
192    /// Whether the ledger agenda (overdue/upcoming prospective records — todos,
193    /// events, reminders) is injected into the main prompt. Free when the
194    /// ledger is empty: the section is simply omitted.
195    #[serde(
196        default = "default_true_memory_ledger_agenda",
197        alias = "memory_ledger_agenda_injection"
198    )]
199    pub ledger_agenda_injection: bool,
200    /// Whether the background ledger gardener runs (expires past events/reminders,
201    /// reconciles record↔schedule drift, distills completed records into durable
202    /// memory). Expiry and reconciliation are deterministic and free; only
203    /// distillation uses the background model, and it no-ops without one.
204    #[serde(default = "default_true_ledger_gardener_enabled")]
205    pub ledger_gardener_enabled: bool,
206    /// Seconds between ledger gardener runs (default 6 hours).
207    #[serde(default = "default_ledger_gardener_interval_secs")]
208    pub ledger_gardener_interval_secs: u64,
209    /// Whether the ledger gardener's distillation pass (completed records →
210    /// durable memories via the background model) is enabled.
211    #[serde(default = "default_true_ledger_distillation_enabled")]
212    pub ledger_distillation_enabled: bool,
213    /// DEPRECATED (memory redesign L3): the "Refine" Dream mode — rewriting the
214    /// notebook from its own prior prose — was retired because a self-referential
215    /// narrative rewrite drifts from durable truth and silently over-merges. The
216    /// notebook is now always a grounded VIEW of the durable memory index (Rebuild)
217    /// or a session bootstrap (Incremental). This field is IGNORED; it is retained
218    /// only so existing config files that set it still deserialize.
219    #[serde(default, alias = "memory_dream_refine_mode")]
220    pub dream_refine_mode: bool,
221    /// Whether the background "gardener" may use the LLM to split/merge "blob" memories.
222    /// Default ON (memory redesign L4). The deterministic blob prefilter is cheap
223    /// and each run is bounded by `gardener_max_splits_per_run`; a run that finds
224    /// nothing, or finds work but has no background model, spends no tokens. Set
225    /// false to opt out.
226    #[serde(
227        default = "default_true_gardener_enabled",
228        alias = "memory_gardener_enabled"
229    )]
230    pub gardener_enabled: bool,
231    /// Seconds between gardener time-triggered runs (default daily). A run may also
232    /// fire early when the library grows — see `gardener_volume_trigger`.
233    #[serde(default = "default_gardener_interval_secs")]
234    pub gardener_interval_secs: u64,
235    /// Run the gardener maintenance pass early (before the next time tick) once this
236    /// many new durable memories have accumulated since the last run, so pileup is
237    /// bounded by growth, not only by the clock (memory redesign L4). 0 disables the
238    /// volume trigger (time-only). Per-run caps still bound the work done.
239    #[serde(default = "default_gardener_volume_trigger")]
240    pub gardener_volume_trigger: usize,
241    /// Hard cap on LLM-backed splits per gardener run (cost ceiling per run).
242    #[serde(default = "default_gardener_max_splits_per_run")]
243    pub gardener_max_splits_per_run: usize,
244    /// Minimum `---` accretions for a memory to be a gardener split candidate.
245    #[serde(default = "default_gardener_min_sections")]
246    pub gardener_min_sections: usize,
247    /// Whether the background dedup gardener may use the LLM to consolidate
248    /// near-duplicate memories. Default ON (memory redesign L4); bounded by
249    /// `dedup_gardener_max_merges_per_run` and no-ops without a model. Set false to
250    /// opt out.
251    #[serde(
252        default = "default_true_dedup_gardener_enabled",
253        alias = "memory_dedup_gardener_enabled"
254    )]
255    pub dedup_gardener_enabled: bool,
256    /// Minimum content-keyword Jaccard (0.0–1.0) for two active memories to be
257    /// flagged as dedup candidates by the deterministic prefilter.
258    #[serde(default = "default_dedup_gardener_min_score")]
259    pub dedup_gardener_min_score: f64,
260    /// Hard cap on LLM-backed consolidations per dedup gardener run (cost ceiling).
261    #[serde(default = "default_dedup_gardener_max_merges_per_run")]
262    pub dedup_gardener_max_merges_per_run: usize,
263    /// Max RECALLABLE (Active/Stale) memories per scope before the capacity gardener
264    /// archives the lowest-value overflow OUT of the recall index (memory redesign
265    /// L5 — archive, never delete; reversible). 0 = unbounded (feature OFF, the
266    /// default): consequential enough to be opt-in, since L4's dedup already curbs
267    /// most growth. `Reference`/`User`/`Feedback` memories are always exempt — so
268    /// the effective floor is the count of exempt Active memories in a scope; set
269    /// this comfortably above that (a capacity below it is a no-op, not a purge).
270    #[serde(default)]
271    pub memory_active_capacity: usize,
272    /// Hard cap on how many memories the capacity gardener archives per run, so a
273    /// large overflow drains gradually instead of in one burst.
274    #[serde(default = "default_capacity_max_archivals_per_run")]
275    pub capacity_max_archivals_per_run: usize,
276    /// Whether the background freshness gardener may conservatively demote Active
277    /// day/week-granularity memories to Stale once they cross their documented
278    /// staleness window (issue #61 phase 2; see
279    /// `bamboo_memory::memory_store::freshness::granularity_expired`). Default ON,
280    /// matching the other gardener passes: deterministic (no LLM, no cost), and
281    /// non-destructive — it only ever moves Active → Stale, never archives or
282    /// deletes. Set false to opt out.
283    #[serde(default = "default_true_granularity_freshness_gardener_enabled")]
284    pub granularity_freshness_gardener_enabled: bool,
285}
286
287impl Default for MemoryConfig {
288    fn default() -> Self {
289        Self {
290            background_model: None,
291            auto_dream_enabled: default_true_auto_dream_enabled(),
292            auto_dream_interval_secs: default_auto_dream_interval_secs(),
293            project_prompt_injection: default_true_memory_project_prompt_injection(),
294            relevant_recall: default_true_memory_relevant_recall(),
295            relevant_recall_rerank: false,
296            project_first_dream: default_true_memory_project_first_dream(),
297            ledger_agenda_injection: default_true_memory_ledger_agenda(),
298            ledger_gardener_enabled: default_true_ledger_gardener_enabled(),
299            ledger_gardener_interval_secs: default_ledger_gardener_interval_secs(),
300            ledger_distillation_enabled: default_true_ledger_distillation_enabled(),
301            dream_refine_mode: false,
302            gardener_enabled: default_true_gardener_enabled(),
303            gardener_interval_secs: default_gardener_interval_secs(),
304            gardener_volume_trigger: default_gardener_volume_trigger(),
305            gardener_max_splits_per_run: default_gardener_max_splits_per_run(),
306            gardener_min_sections: default_gardener_min_sections(),
307            dedup_gardener_enabled: default_true_dedup_gardener_enabled(),
308            dedup_gardener_min_score: default_dedup_gardener_min_score(),
309            dedup_gardener_max_merges_per_run: default_dedup_gardener_max_merges_per_run(),
310            memory_active_capacity: 0,
311            capacity_max_archivals_per_run: default_capacity_max_archivals_per_run(),
312            granularity_freshness_gardener_enabled:
313                default_true_granularity_freshness_gardener_enabled(),
314        }
315    }
316}
317
318fn default_true_granularity_freshness_gardener_enabled() -> bool {
319    true
320}
321
322fn default_capacity_max_archivals_per_run() -> usize {
323    50
324}
325
326fn default_true_auto_dream_enabled() -> bool {
327    true
328}
329
330fn default_true_gardener_enabled() -> bool {
331    true
332}
333
334fn default_true_dedup_gardener_enabled() -> bool {
335    true
336}
337
338fn default_true_memory_ledger_agenda() -> bool {
339    true
340}
341
342fn default_true_ledger_gardener_enabled() -> bool {
343    true
344}
345
346fn default_ledger_gardener_interval_secs() -> u64 {
347    21_600
348}
349
350fn default_true_ledger_distillation_enabled() -> bool {
351    true
352}
353
354/// Fire the gardener maintenance pass early once ~this many new memories accumulate
355/// since the last run. Conservative: large enough to avoid thrashing on a few
356/// writes, small enough to bound pileup well under a full (daily) interval.
357fn default_gardener_volume_trigger() -> usize {
358    25
359}
360
361fn default_gardener_interval_secs() -> u64 {
362    86_400
363}
364
365fn default_auto_dream_interval_secs() -> u64 {
366    60 * 30
367}
368
369fn default_gardener_max_splits_per_run() -> usize {
370    8
371}
372
373fn default_gardener_min_sections() -> usize {
374    5
375}
376
377fn default_dedup_gardener_min_score() -> f64 {
378    0.6
379}
380
381fn default_dedup_gardener_max_merges_per_run() -> usize {
382    8
383}
384
385fn default_true_memory_project_prompt_injection() -> bool {
386    true
387}
388
389fn default_true_memory_relevant_recall() -> bool {
390    true
391}
392
393fn default_true_memory_project_first_dream() -> bool {
394    true
395}
396
397/// Per-run resource guardrails (issue #221): a cost/resource ceiling applied
398/// across an entire `AgentRuntime::execute()` call (i.e. one user turn's worth
399/// of internal rounds — the same "run" granularity `max_rounds` already uses).
400///
401/// Every field is `None` by default (unlimited), matching the rest of this
402/// config's opt-in-only posture. A per-request `ExecuteRequest::run_budget`
403/// override (HTTP `POST /execute` body) may only TIGHTEN this config-level
404/// default, never loosen it — per field, the effective limit is the minimum
405/// of the two (see [`RunBudgetConfig::merged_with_override`] and
406/// `bamboo_engine::runtime::runtime::AgentRuntime::execute`).
407///
408/// Exceeding any configured limit gracefully stops the run (mirrors the
409/// `max_rounds` exhaustion path: one final summary turn, then a terminal stop
410/// with `runtime.completion_reason = "budget_exceeded"` on the session, plus a
411/// structured `AgentEvent::BudgetExceeded`) rather than erroring out — the run
412/// stays resumable.
413#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
414pub struct RunBudgetConfig {
415    /// Maximum total tokens (prompt + completion, actual provider-reported
416    /// usage summed across the run's rounds) before the run is stopped.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub max_total_tokens: Option<u64>,
419    /// Maximum total tool calls (across every round of the run, not just one
420    /// round — see `max_tool_calls_per_round` for the existing per-round cap)
421    /// before the run is stopped.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub max_tool_calls: Option<u32>,
424    /// Maximum total `SubAgent` create calls (across the whole run) before the
425    /// run is stopped. Distinct from `subagents.max_concurrent`, which caps how
426    /// many child actor processes run AT ONCE, not how many a single run may
427    /// spawn in total over its lifetime.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub max_subagents: Option<u32>,
430}
431
432/// Tighten-only per-field merge: the effective limit is the MINIMUM of the
433/// config default and the request override, with `None` = unlimited.
434fn min_limit<T: Ord + Copy>(config_default: Option<T>, request: Option<T>) -> Option<T> {
435    match (config_default, request) {
436        (Some(a), Some(b)) => Some(a.min(b)),
437        (Some(a), None) => Some(a),
438        (None, Some(b)) => Some(b),
439        (None, None) => None,
440    }
441}
442
443impl RunBudgetConfig {
444    /// Merge a per-request override with this config-level default,
445    /// **tighten-only** (issue #221, PR #539 review): per field, the
446    /// effective limit is the MINIMUM of the two (`None` = unlimited), so a
447    /// `POST /execute` caller can lower a budget below the operator's
448    /// configured ceiling but can never raise or remove it.
449    ///
450    /// Rationale: `run_budget` is a defensive cost circuit-breaker, and the
451    /// server's other guardrails (`max_rounds`, per-round tool caps, …) are
452    /// not client-overridable at all. A client-loosenable ceiling would be no
453    /// ceiling: any caller of `/execute` could send
454    /// `max_total_tokens: u64::MAX` and erase the operator's cap. Overrides
455    /// looser than the config default are silently clamped to it rather than
456    /// rejected — the caller still gets the strictest applicable budget,
457    /// which is always a safe interpretation of their request.
458    pub fn merged_with_override(&self, request_override: Option<&RunBudgetConfig>) -> Self {
459        let Some(over) = request_override else {
460            return *self;
461        };
462        Self {
463            max_total_tokens: min_limit(self.max_total_tokens, over.max_total_tokens),
464            max_tool_calls: min_limit(self.max_tool_calls, over.max_tool_calls),
465            max_subagents: min_limit(self.max_subagents, over.max_subagents),
466        }
467    }
468}
469
470/// Sub-agent execution settings.
471///
472/// Sub-agents always run as independent **actor** processes — an isolated OS
473/// process with its own context (crash isolation, true parallelism, per-child
474/// resource limits). The historical in-process runtime was removed, so there is
475/// no longer a runtime toggle (a stray `"runtime"`/`"overrides"` key in an old
476/// config is ignored). The worker binary, its arguments, and the discovery
477/// directory are derived automatically (the current `bamboo` executable +
478/// `subagent-worker`); the expert fields below override them only when you run a
479/// custom worker.
480#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
481pub struct SubagentsConfig {
482    /// Maximum actor processes running at once; further spawns wait their
483    /// turn. Default: 8.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub max_concurrent: Option<usize>,
486    /// Expert: custom worker binary. Default: the current bamboo executable.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub worker_bin: Option<String>,
489    /// Expert: arguments for the custom worker binary. Default for the
490    /// built-in worker: `["subagent-worker"]`.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub worker_args: Option<Vec<String>>,
493    /// Expert: discovery fabric directory. Default: a per-user temp dir.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub fabric_dir: Option<String>,
496    /// Expert: `"echo"` swaps in a dependency-free smoke executor (no LLM)
497    /// to verify the actor chain end-to-end; `"claude_code"` drives the
498    /// official Claude Code CLI (see the `claude_code_*` fields below).
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub executor: Option<String>,
501    /// `executor = "claude_code"` only: override the `claude` executable.
502    /// `None` runs `claude` resolved from `PATH`.
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub claude_code_binary: Option<String>,
505    /// `executor = "claude_code"` only: `--model` override. `None` omits the
506    /// flag (CLI default).
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub claude_code_model: Option<String>,
509    /// `executor = "claude_code"` only: `--permission-mode` override. `None`
510    /// still passes an EXPLICIT `default` to the CLI (issue #443 — the
511    /// headless stream-json default is `auto`, which self-approves every
512    /// tool and never asks); it does not mean "omit the flag".
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub claude_code_permission_mode: Option<String>,
515    /// `executor = "claude_code"` only: `true` lets the child inherit the
516    /// invoking user's `~/.claude` MCP servers/skills/settings. `false`/unset
517    /// (the default) isolates it (`--strict-mcp-config` +
518    /// `--setting-sources project`).
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub claude_code_inherit_user_config: Option<bool>,
521    /// `executor = "claude_code"` only: extra env var NAMES forwarded
522    /// verbatim from this process's env to the child, on top of the fixed
523    /// HOME/PATH/SHELL/TERM/LANG/LC_*/TMPDIR/USER/LOGNAME allowlist.
524    /// Forwarding `ANTHROPIC_API_KEY` here is an explicit opt-in that flips
525    /// billing from the CLI's own subscription auth to the API key.
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub claude_code_forward_env: Option<Vec<String>>,
528    /// The active message-broker endpoint the `ask_agent` tool / sub-agent bus
529    /// dials. RUNTIME-ONLY (`#[serde(skip)]`): never read from nor written to
530    /// `config.json`. It is populated in memory each boot by `maybe_embed_broker`
531    /// — either from a user-managed external broker in `<data_dir>/broker.json`,
532    /// or from the freshly-embedded in-process broker (whose ephemeral loopback
533    /// port must NEVER be persisted, else a later boot dials a dead port).
534    #[serde(skip)]
535    pub broker: Option<BrokerClientConfig>,
536    /// Remote placements: pin specific sub-agent roles to resident workers
537    /// reached over `wss://` instead of a locally-spawned subprocess
538    /// (remote-actor-plan §3.4 / P1.5, #193). Empty (the default) keeps every
539    /// role on the local path — fully back-compatible: an old config with no
540    /// `remote_placements` key deserializes to an empty vec.
541    #[serde(default, skip_serializing_if = "Vec::is_empty")]
542    pub remote_placements: Vec<RemoteActorPlacement>,
543    /// Schedulable placements: route specific sub-agent roles to a LIVE worker
544    /// resolved from the agent registry at run time, instead of a locally-spawned
545    /// subprocess (remote-actor-plan §3.4 / P2b, #181). Unlike `remote_placements`
546    /// (a fixed endpoint), a schedulable placement names a logical `pool` and a
547    /// `registry_url`; the engine queries the registry for live workers in that
548    /// pool and picks one. Empty (the default) keeps every role on the local path
549    /// — fully back-compatible: an old config with no `schedulable_placements` key
550    /// deserializes to an empty vec.
551    ///
552    /// PRECEDENCE: if a role appears in BOTH `remote_placements` and
553    /// `schedulable_placements`, the fixed remote placement wins (it is resolved
554    /// first in `build_spec`).
555    #[serde(default, skip_serializing_if = "Vec::is_empty")]
556    pub schedulable_placements: Vec<SchedulablePlacement>,
557    /// Per-role allowlist scoping which host-bound MCP tools a sub-agent role
558    /// may see/call through the orchestrator's MCP proxy (issue #54;
559    /// `bamboo_broker::RoleToolAllowlist`). Read and enforced
560    /// ORCHESTRATOR-side when wiring `serve_mcp_proxy` — this is deliberately
561    /// NOT part of the worker-facing `McpProxyConfig` a deployed worker
562    /// receives, because a worker self-declaring its own allowlist would be
563    /// insecure (it could simply claim to be unrestricted). A role absent
564    /// from this list is unrestricted (sees/can call every proxiable tool),
565    /// so adding this policy never silently strips tools from an
566    /// already-deployed role you have not listed here. Empty (the default)
567    /// keeps every role unrestricted — fully backward compatible with
568    /// pre-#54 behavior.
569    ///
570    /// Role AND tool names are matched by exact string equality against the
571    /// worker-asserted `AgentRef.role` / the requested tool name — see
572    /// `RoleToolAllowlist`'s doc comment for the resulting self-asserted-role
573    /// caveat (this policy is adequate against a confused/hallucinating
574    /// worker, not a malicious one that lies about its own role).
575    #[serde(default, skip_serializing_if = "Vec::is_empty")]
576    pub mcp_role_allowlist: Vec<McpRoleAllowlistEntry>,
577}
578
579/// One role's MCP proxy tool allowlist entry (issue #54). See
580/// [`SubagentsConfig::mcp_role_allowlist`].
581#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
582pub struct McpRoleAllowlistEntry {
583    /// Sub-agent role this entry restricts — matches the worker-asserted
584    /// `AgentRef.role` (itself the child session's `subagent_type` /
585    /// `ChildIdentity.role`). There is no fixed registry of valid roles in
586    /// this codebase (roles are free-form profile ids), so a typo here is
587    /// NOT caught against a "known roles" list — only structurally (blank
588    /// names are dropped, duplicates warn) at load time. Double-check this
589    /// against the role string your profile/deploy config actually uses.
590    pub role: String,
591    /// Tool names this role may see in its manifest / call through the
592    /// proxy, matched by exact string equality against the backend's
593    /// registered tool name. An entry with an EMPTY list is an explicit
594    /// lockout (no tools) for that role, distinct from the role being absent
595    /// from this Vec entirely (unrestricted). Validated at load time against
596    /// the orchestrator's live MCP tool set where available — an unknown
597    /// name is still enforced (kept) but logged as a likely typo.
598    #[serde(default)]
599    pub tools: Vec<String>,
600}
601
602/// Routes a single sub-agent role to a registry-scheduled worker (remote-actor-
603/// plan §3.4 / P2b, #181). A child whose `subagent_type` matches `role` is run on
604/// a LIVE worker chosen from the agent registry: the engine builds a
605/// `RegistryFabric` at `registry_url`, lists live workers (the registry already
606/// excludes expired leases), filters to those whose `role` == `pool`, picks one
607/// (round-robin), and connects over `wss://` (Bearer-authenticated). If no live
608/// worker exists the run ERRORS — a schedulable role NEVER falls back to a local
609/// subprocess (that would silently defeat the placement).
610///
611/// The bearer token is NEVER stored here in the clear: `token_env` names the
612/// environment variable that holds it (mirroring `RemoteActorPlacement` /
613/// the A2A `auth_ref` pattern), read once at runner-build time and used for BOTH
614/// the registry query AND the worker connect. A `token_env` that is set-but-unset
615/// at build time fails SAFE — the placement is skipped and the role falls back to
616/// Local rather than querying/connecting unauthenticated.
617#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
618pub struct SchedulablePlacement {
619    /// Sub-agent role this targets (matches the child session's
620    /// `metadata["subagent_type"]`).
621    pub role: String,
622    /// Logical pool name — the registry `role` to query for live workers.
623    pub pool: String,
624    /// VESTIGIAL (Phase 3 retired the HTTP agent registry — pools are now bus
625    /// roles resolved via broker presence). Kept for config back-compat; ignored
626    /// by the resolver. Optional so a placement is just `{role, pool}`.
627    #[serde(default, skip_serializing_if = "String::is_empty")]
628    pub registry_url: String,
629    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
630    /// `auth_ref`). Used for BOTH the registry query and the worker connect.
631    /// `None` ⇒ query/connect without a bearer (trusted link only).
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    pub token_env: Option<String>,
634    /// PEM file pinning a self-signed worker/registry cert. `None` ⇒ default
635    /// webpki roots.
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub ca_cert_file: Option<String>,
638}
639
640/// Pins a single sub-agent role to a remote resident worker (remote-actor-plan
641/// §3.4 / P1.5). A child whose `subagent_type` matches `role` is connected over
642/// `wss://` to `endpoint` (Bearer-authenticated) instead of being spawned as a
643/// local subprocess. No role match ⇒ that child stays on the local path.
644///
645/// The bearer token is NEVER stored here in the clear: `token_env` names the
646/// environment variable that holds it (mirroring the A2A `auth_ref` pattern),
647/// read once at runner-build time. A `token_env` that is set-but-unset at build
648/// time fails SAFE — the placement is skipped and the role falls back to Local
649/// rather than connecting unauthenticated.
650#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
651pub struct RemoteActorPlacement {
652    /// Sub-agent role this targets (matches the child session's
653    /// `metadata["subagent_type"]`).
654    pub role: String,
655    /// Resident worker endpoint, e.g. `wss://gpu-host:8443` (or `ws://` only on
656    /// a trusted/loopback link).
657    pub endpoint: String,
658    /// Env var holding the bearer token (NOT the raw token — mirrors A2A
659    /// `auth_ref`). `None` ⇒ connect without a bearer (trusted link only).
660    #[serde(default, skip_serializing_if = "Option::is_none")]
661    pub token_env: Option<String>,
662    /// PEM file pinning a self-signed worker cert. `None` ⇒ default webpki roots
663    /// (or plaintext `ws://`).
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub ca_cert_file: Option<String>,
666}
667
668/// How to reach the central sub-agent message broker (`bamboo broker serve`).
669#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
670pub struct BrokerClientConfig {
671    /// Broker WebSocket endpoint, e.g. `ws://broker-host:9600`.
672    pub endpoint: String,
673    /// Bearer token presented in the broker handshake.
674    ///
675    /// Secret: encrypted at rest in `token_encrypted`; this plaintext field is
676    /// empty on disk and hydrated in memory on load (mirrors [`EnvVarEntry`]).
677    #[serde(default)]
678    pub token: String,
679    /// Encrypted ciphertext of `token` (the at-rest representation).
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub token_encrypted: Option<String>,
682}
683
684/// Native desktop (OS-notification) delivery channel.
685#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
686pub struct DesktopChannelConfig {
687    /// `None` = auto: on when Bamboo runs as a standalone `bamboo serve`
688    /// process, off when spawned as a sidecar under `--parent-pid` (a native
689    /// shell such as Bodhi owns notification UX in that mode — desktop
690    /// notifications from both the sidecar and the shell would double-fire).
691    /// `Some(_)` is an explicit user override of that default in either
692    /// direction.
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub enabled: Option<bool>,
695}
696
697/// [ntfy.sh](https://ntfy.sh) push notification channel (self-hostable).
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
699pub struct NtfyChannelConfig {
700    #[serde(default)]
701    pub enabled: bool,
702    /// ntfy server base URL (public ntfy.sh or a self-hosted instance).
703    #[serde(default = "default_ntfy_base_url")]
704    pub base_url: String,
705    /// Topic to publish to. Priority mapping from notification category is
706    /// left to the delivery sink, not configured here.
707    #[serde(default)]
708    pub topic: String,
709    /// Access token for a protected/self-hosted ntfy instance (public ntfy.sh
710    /// topics need none).
711    ///
712    /// Secret: encrypted at rest in `token_encrypted`; this plaintext field is
713    /// never serialized and is hydrated in memory on load (mirrors
714    /// [`EnvVarEntry`] / [`BrokerClientConfig::token`]).
715    #[serde(default, skip_serializing)]
716    pub token: Option<String>,
717    /// Encrypted ciphertext of `token` (the at-rest representation).
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub token_encrypted: Option<String>,
720}
721
722impl Default for NtfyChannelConfig {
723    fn default() -> Self {
724        Self {
725            enabled: false,
726            base_url: default_ntfy_base_url(),
727            topic: String::new(),
728            token: None,
729            token_encrypted: None,
730        }
731    }
732}
733
734fn default_ntfy_base_url() -> String {
735    "https://ntfy.sh".to_string()
736}
737
738/// [Bark](https://github.com/Finb/Bark) iOS push notification channel.
739#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
740pub struct BarkChannelConfig {
741    #[serde(default)]
742    pub enabled: bool,
743    /// Bark server base URL (public api.day.app or a self-hosted instance).
744    #[serde(default = "default_bark_base_url")]
745    pub base_url: String,
746    /// Bark device key identifying the target iOS device.
747    ///
748    /// Secret: encrypted at rest in `device_key_encrypted`; this plaintext
749    /// field is never serialized and is hydrated in memory on load (mirrors
750    /// [`NtfyChannelConfig::token`]).
751    #[serde(default, skip_serializing)]
752    pub device_key: Option<String>,
753    /// Encrypted ciphertext of `device_key` (the at-rest representation).
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub device_key_encrypted: Option<String>,
756}
757
758impl Default for BarkChannelConfig {
759    fn default() -> Self {
760        Self {
761            enabled: false,
762            base_url: default_bark_base_url(),
763            device_key: None,
764            device_key_encrypted: None,
765        }
766    }
767}
768
769fn default_bark_base_url() -> String {
770    "https://api.day.app".to_string()
771}
772
773/// Notification delivery channels: native desktop plus push-relay services.
774///
775/// Additive/back-compat: an absent `notifications` key in `config.json`
776/// deserializes to the defaults (desktop auto, ntfy/bark disabled).
777#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
778pub struct NotificationsConfig {
779    #[serde(default)]
780    pub desktop: DesktopChannelConfig,
781    #[serde(default)]
782    pub ntfy: NtfyChannelConfig,
783    #[serde(default)]
784    pub bark: BarkChannelConfig,
785}
786
787/// One IM-platform bridge configured under `[[connect.platforms]]` —
788/// bamboo-connect (issue #452 / epic #447): drives a bamboo session from an
789/// external chat platform (Telegram first).
790#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
791pub struct ConnectPlatformConfig {
792    /// Stable per-entry identifier (#496). Not part of the original schema —
793    /// absent on legacy/hand-written entries and on a freshly-echoed new
794    /// entry from a client. [`Config::save_to_dir`] assigns one (a random
795    /// UUID) to any entry that lacks it as part of the normal save path
796    /// (migration-on-write); load never mutates/rewrites the config to
797    /// backfill it, per #493's never-overwrite-until-confirmed semantics.
798    ///
799    /// Used by [`crate::patch::preserve_masked_connect_secrets`] as the
800    /// FIRST resolution strategy for a masked secret in a settings PATCH,
801    /// ahead of the positional/`type`-based fallbacks (#490/#492) — an exact
802    /// id match unambiguously identifies the same logical entry even when
803    /// two entries share the same `platform_type` and have been reordered,
804    /// which position+type alone cannot always disambiguate.
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub id: Option<String>,
807    /// Platform adapter selector, e.g. `"telegram"`. Unrecognized values are
808    /// skipped (with a startup warning) rather than failing config load —
809    /// forward-compatible with future adapters (Feishu/Slack).
810    #[serde(rename = "type")]
811    pub platform_type: String,
812    /// Platform bot/API token.
813    ///
814    /// Secret: encrypted at rest in `token_encrypted`; this plaintext field is
815    /// never serialized and is hydrated in memory on load (mirrors
816    /// [`NtfyChannelConfig::token`] / [`BarkChannelConfig::device_key`]).
817    #[serde(default, skip_serializing)]
818    pub token: Option<String>,
819    /// Encrypted ciphertext of `token` (the at-rest representation).
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub token_encrypted: Option<String>,
822    /// Platform app id (Feishu `app_id`). Not a secret — serialized normally.
823    /// Unused by the Telegram adapter.
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub app_id: Option<String>,
826    /// Platform app secret (Feishu `app_secret`).
827    ///
828    /// Secret: encrypted at rest in `app_secret_encrypted`; this plaintext
829    /// field is never serialized and is hydrated in memory on load (mirrors
830    /// `token` above).
831    #[serde(default, skip_serializing)]
832    pub app_secret: Option<String>,
833    /// Encrypted ciphertext of `app_secret` (the at-rest representation).
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub app_secret_encrypted: Option<String>,
836    /// Platform domain/base-URL selector (Feishu-only today). Not a secret —
837    /// serialized normally. `None`/`"feishu"` -> open.feishu.cn, `"lark"` ->
838    /// open.larksuite.com, an `https://` value -> a private-deployment base
839    /// URL used verbatim. Validation happens in the server registration arm,
840    /// not here.
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub domain: Option<String>,
843    /// Platform-scoped user ids allowed to drive a session. Deliberately
844    /// STRICTER than the general secret-mask precedents: an EMPTY list means
845    /// deny-all (every inbound message is rejected), not allow-all — a
846    /// startup warning is logged when a platform has no allowed users.
847    #[serde(default)]
848    pub allow_from: Vec<String>,
849    /// User ids allowed to run privileged/admin commands. Parsed from day one
850    /// but UNUSED in the MVP (#452) — no admin commands exist yet; reserved
851    /// for the approvals/admin phase of epic #447.
852    #[serde(default)]
853    pub admin_from: Vec<String>,
854}
855
856/// bamboo-connect platform bridges: drive bamboo sessions from IM platforms.
857/// Additive/back-compat: an absent `connect` key in `config.json`
858/// deserializes to an empty platform list — fully inert (#452).
859#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
860pub struct ConnectConfig {
861    #[serde(default)]
862    pub platforms: Vec<ConnectPlatformConfig>,
863}
864
865fn connect_config_is_empty(config: &ConnectConfig) -> bool {
866    config.platforms.is_empty()
867}
868
869/// One publisher key trusted to sign plugin bundles.
870///
871/// `algorithm` is a plain string (not an enum) so an unrecognized future value
872/// in an old/new config just never matches during verification rather than
873/// failing to deserialize — additive/forward-compatible, matching this
874/// crate's other config sections. Only `"ed25519"` is understood today.
875#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
876pub struct TrustedKey {
877    /// Human-readable label (surfaced in logs/CLI output); purely descriptive.
878    pub label: String,
879    /// Signature algorithm. Only `"ed25519"` is currently verified.
880    pub algorithm: String,
881    /// Hex-encoded public key (32 raw bytes for ed25519).
882    pub public_key: String,
883}
884
885/// The official plugin-signing keys trusted by default, so an out-of-the-box
886/// `bamboo plugin install <official release url>` needs no `--allow-unsigned`
887/// for a bundle those repos' release CI signed. One entry per first-party
888/// plugin publisher; each repo commits its public half as
889/// `packaging/plugin/signing-key.pub` (nova) / `plugin/signing-key.pub`
890/// (magpie) for cross-checking.
891fn default_trusted_keys() -> Vec<TrustedKey> {
892    vec![
893        TrustedKey {
894            label: "nova (bigduu official)".to_string(),
895            algorithm: "ed25519".to_string(),
896            public_key: "e3c429e1be50098b12c6f45737abf457189b668535875b5b3e2b4349be86ea59"
897                .to_string(),
898        },
899        TrustedKey {
900            label: "magpie (bigduu official)".to_string(),
901            algorithm: "ed25519".to_string(),
902            public_key: "47e971c39cd93adb18cff50e097cb387df49e9c4d33b0ed62f693eabbe7fc66e"
903                .to_string(),
904        },
905    ]
906}
907
908/// Default trusted host+path prefix: the `bigduu` GitHub org/user's own repos
909/// (e.g. `github.com/bigduu/Nova/releases/...`).
910fn default_trusted_hosts() -> Vec<String> {
911    vec!["github.com/bigduu/".to_string()]
912}
913
914/// Plugin URL-install source-trust policy: a host allowlist (is the SOURCE
915/// authorized?) plus ed25519 publisher keys (is the PUBLISHER authentic?).
916/// This stacks on top of the checksum layer already enforced in
917/// `bamboo_plugin::registry::PluginSource::Url` (are the BYTES what the
918/// caller expected?) — see `bamboo-server`'s `plugin_source.rs` for where all
919/// three layers are enforced together. A pasted checksum alone cannot
920/// establish source trust (an attacker who controls the page a checksum was
921/// copied from can just publish a checksum for their own tampered bundle);
922/// the host allowlist and signature checks close that gap.
923///
924/// Both fields are user-editable (`config.json`, or the config-set HTTP/CLI
925/// path) so an operator can add their own trusted hosts/keys. Additive/
926/// back-compat: an absent `plugin_trust` key deserializes to
927/// [`PluginTrustConfig::default`] (the built-in defaults below), not an empty
928/// policy — so a fresh install can trust the official nova plugin out of the
929/// box.
930#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
931pub struct PluginTrustConfig {
932    /// Host+path prefixes a `url` plugin source may be fetched from without
933    /// `--allow-untrusted-host`, e.g. `"github.com/bigduu/"` (a bare host
934    /// with no `/`, e.g. `"example.com"`, matches any path on that exact
935    /// host). Each entry is split into a host component and a path-prefix
936    /// component and matched on PARSED URL components — whole-host equality
937    /// plus a `/`-boundary path-prefix check, never a raw string
938    /// `starts_with` — see [`is_host_trusted`] for the precise rule and why
939    /// (it closes a domain-gluing bypass like `example.com` matching
940    /// `example.com.evil.com`, and a sibling-path bypass like
941    /// `github.com/bigduu` matching `github.com/bigduu-evil/x`).
942    #[serde(default = "default_trusted_hosts")]
943    pub trusted_hosts: Vec<String>,
944    /// Publisher keys a bundle's `.sig` signature may verify against without
945    /// `--allow-unsigned`.
946    #[serde(default = "default_trusted_keys")]
947    pub trusted_keys: Vec<TrustedKey>,
948    /// Persistent, config-level escape hatch for the whole three-layer
949    /// policy above: `Off` makes every `url` plugin install/update behave as
950    /// if `--insecure` (equivalently, `--allow-untrusted-host
951    /// --allow-unsigned --allow-unverified`) were passed, WITHOUT needing the
952    /// per-install flag every time — the "I run a private/dev bamboo and
953    /// don't want to pass flags on every install" customization. Defaults to
954    /// [`PluginTrustEnforcement::Strict`] — a fresh config, or one with no
955    /// `plugin_trust.enforcement` key at all, is secure by default; relaxing
956    /// it is always an explicit, user-initiated edit
957    /// (`bamboo config set plugin_trust.enforcement off`), never a silent
958    /// weakening. See `bamboo-server`'s `plugin_source.rs` for where this is
959    /// enforced, and `AppState::new` for the loud startup warning emitted
960    /// whenever a server boots with this set to `Off`.
961    #[serde(default)]
962    pub enforcement: PluginTrustEnforcement,
963}
964
965impl Default for PluginTrustConfig {
966    fn default() -> Self {
967        Self {
968            trusted_hosts: default_trusted_hosts(),
969            trusted_keys: default_trusted_keys(),
970            enforcement: PluginTrustEnforcement::default(),
971        }
972    }
973}
974
975impl PluginTrustConfig {
976    /// True when `url` is `https` and its host+path match one of
977    /// `trusted_hosts` on parsed URL components (host compared
978    /// case-insensitively as a WHOLE string, path matched on a `/` boundary
979    /// — see the free function [`is_host_trusted`] for the precise rule; an
980    /// unparseable URL or a non-`https` scheme is never trusted).
981    pub fn is_host_trusted(&self, url: &str) -> bool {
982        is_host_trusted(url, &self.trusted_hosts)
983    }
984
985    /// True when `enforcement` is [`PluginTrustEnforcement::Off`] — every
986    /// `url` plugin install/update should skip the host allowlist, signature,
987    /// and checksum-requirement layers, exactly as if `--insecure` were
988    /// passed to that individual install. See the field's doc comment for
989    /// the full rationale.
990    pub fn enforcement_is_off(&self) -> bool {
991        matches!(self.enforcement, PluginTrustEnforcement::Off)
992    }
993}
994
995/// `plugin_trust.enforcement`: the persistent, config-level form of the
996/// `--insecure` escape hatch (see [`PluginTrustConfig::enforcement`]).
997///
998/// Deserialization accepts either the canonical string form (`"strict"` /
999/// `"off"`, case-insensitive) or a bool-ish alias (`true` == `Strict`,
1000/// `false` == `Off`) for a hand-edited `config.json` — `true`/`false` read
1001/// naturally as "is enforcement on?". The string form is what
1002/// `bamboo config set plugin_trust.enforcement off` writes (and the only
1003/// form the generic dot-path setter's round-trip check accepts on write,
1004/// since this type always *serializes* back out as a string — see
1005/// `bamboo-config`'s `dot_path` module); the bool alias is a read-side
1006/// convenience for whoever edits `config.json` directly.
1007#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
1008#[serde(rename_all = "snake_case")]
1009pub enum PluginTrustEnforcement {
1010    /// Secure by default: the host allowlist, signature, and checksum layers
1011    /// are all enforced (each individually waivable via
1012    /// `--allow-untrusted-host` / `--allow-unsigned` / `--allow-unverified`,
1013    /// or all at once via `--insecure`).
1014    #[default]
1015    Strict,
1016    /// Every `url` plugin install/update skips all three trust layers,
1017    /// without needing any per-install flag. Opt-in only — never the
1018    /// default for a fresh or pre-existing config.
1019    Off,
1020}
1021
1022impl<'de> Deserialize<'de> for PluginTrustEnforcement {
1023    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1024    where
1025        D: serde::Deserializer<'de>,
1026    {
1027        #[derive(Deserialize)]
1028        #[serde(untagged)]
1029        enum Repr {
1030            Bool(bool),
1031            Str(String),
1032        }
1033        match Repr::deserialize(deserializer)? {
1034            Repr::Bool(true) => Ok(PluginTrustEnforcement::Strict),
1035            Repr::Bool(false) => Ok(PluginTrustEnforcement::Off),
1036            Repr::Str(raw) => match raw.trim().to_ascii_lowercase().as_str() {
1037                "strict" => Ok(PluginTrustEnforcement::Strict),
1038                "off" => Ok(PluginTrustEnforcement::Off),
1039                other => Err(serde::de::Error::custom(format!(
1040                    "invalid `plugin_trust.enforcement` value '{other}': expected \"strict\" or \
1041                     \"off\""
1042                ))),
1043            },
1044        }
1045    }
1046}
1047
1048/// One `trusted_hosts` entry, split into its host and path-prefix
1049/// components (see [`is_host_trusted`]). `path_prefix` is empty for a
1050/// bare-host entry (e.g. `"example.com"`, meaning "any path on this exact
1051/// host") or starts with `/` (e.g. `"/bigduu/"` from `"github.com/bigduu/"`).
1052struct TrustedHostEntry<'a> {
1053    host: &'a str,
1054    path_prefix: &'a str,
1055}
1056
1057/// Split a raw `trusted_hosts` entry at its first `/` into host + path
1058/// components. Entries are compared against ALREADY-lowercased input by the
1059/// caller ([`is_host_trusted`]), so this does no case normalization itself.
1060fn parse_trusted_host_entry(entry: &str) -> TrustedHostEntry<'_> {
1061    match entry.find('/') {
1062        Some(index) => TrustedHostEntry {
1063            host: &entry[..index],
1064            path_prefix: &entry[index..],
1065        },
1066        None => TrustedHostEntry {
1067            host: entry,
1068            path_prefix: "",
1069        },
1070    }
1071}
1072
1073/// True when `path` matches `prefix` on a `/` path-component boundary, never
1074/// on a raw byte prefix: exactly equal to `prefix`, or `prefix` ends in `/`
1075/// and `path` starts with it, or the character in `path` immediately
1076/// following `prefix` is `/`. An empty `prefix` (a bare-host trusted_hosts
1077/// entry) matches any path.
1078///
1079/// This is what stops a sibling path from passing as a prefix match — e.g.
1080/// entry `github.com/bigduu` (no trailing slash) must NOT match
1081/// `github.com/bigduu-evil/x`: `"/bigduu-evil/x"` starts with `"/bigduu"` as
1082/// raw bytes, but the character right after the prefix is `-`, not `/`, so
1083/// this correctly refuses it.
1084fn path_matches_prefix(path: &str, prefix: &str) -> bool {
1085    if prefix.is_empty() || path == prefix {
1086        return true;
1087    }
1088    if prefix.ends_with('/') {
1089        return path.starts_with(prefix);
1090    }
1091    path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
1092}
1093
1094/// Free function backing [`PluginTrustConfig::is_host_trusted`] — exposed
1095/// separately so callers (and tests) can check a candidate host list without
1096/// constructing a full [`PluginTrustConfig`]/[`Config`].
1097///
1098/// Matches on PARSED URL COMPONENTS, not a raw string prefix: `url` must be
1099/// `https`, its `host_str()` (already correct for userinfo — `user@host` or
1100/// `host@evil.com`-style tricks resolve to the real host, not a decoy — and
1101/// for an explicit port, which `host_str()` excludes) must EQUAL a
1102/// `trusted_hosts` entry's host component (case-insensitively, WHOLE host —
1103/// never a `starts_with`), and its (already dot-segment-normalized by
1104/// `Url::parse`) path must match that entry's path-prefix component on a `/`
1105/// boundary (see [`path_matches_prefix`]). A bare-host entry (no `/` in it)
1106/// has an empty path-prefix, so it matches any path but ONLY on that exact
1107/// host.
1108///
1109/// A prior raw-`starts_with` implementation was defeated by (1) gluing a
1110/// trusted bare host into a longer attacker-controlled one, e.g.
1111/// `trusted.example.com` matching `trusted.example.com.evil.com` /
1112/// `trusted.example.comevil.com`, and (2) a sibling path prefix, e.g.
1113/// `github.com/bigduu` (no trailing slash) matching
1114/// `github.com/bigduu-evil/x`. Component-wise matching closes both: host
1115/// comparison is whole-string equality (no gluing possible), and the path
1116/// check enforces a `/` boundary (no sibling-prefix bypass possible).
1117pub fn is_host_trusted(url: &str, trusted_hosts: &[String]) -> bool {
1118    let Ok(parsed) = url::Url::parse(url) else {
1119        return false;
1120    };
1121    if parsed.scheme() != "https" {
1122        return false;
1123    }
1124    let Some(host) = parsed.host_str() else {
1125        return false;
1126    };
1127    let host = host.to_ascii_lowercase();
1128    let path = parsed.path();
1129
1130    trusted_hosts.iter().any(|raw_entry| {
1131        let entry = raw_entry.trim().to_ascii_lowercase();
1132        let parsed_entry = parse_trusted_host_entry(&entry);
1133        host == parsed_entry.host && path_matches_prefix(path, parsed_entry.path_prefix)
1134    })
1135}
1136
1137/// Main configuration structure for Bamboo agent
1138///
1139/// Contains all settings needed to run the agent, including provider credentials,
1140/// proxy settings, model selection, and server configuration.
1141#[derive(Debug, Clone, Serialize, Deserialize)]
1142pub struct Config {
1143    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
1144    #[serde(default)]
1145    pub http_proxy: String,
1146    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
1147    #[serde(default)]
1148    pub https_proxy: String,
1149    /// Proxy authentication credentials
1150    ///
1151    /// Note: this is kept in-memory only. On disk we store `proxy_auth_encrypted`.
1152    #[serde(skip_serializing)]
1153    pub proxy_auth: Option<ProxyAuth>,
1154    /// Encrypted proxy authentication credentials (nonce:ciphertext)
1155    ///
1156    /// This is the at-rest storage representation. When present, Bamboo will
1157    /// decrypt it into `proxy_auth` at load time.
1158    #[serde(default, skip_serializing_if = "Option::is_none")]
1159    pub proxy_auth_encrypted: Option<String>,
1160    /// Deprecated: Use `providers.copilot.headless_auth` instead
1161    #[serde(default)]
1162    pub headless_auth: bool,
1163
1164    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
1165    #[serde(default = "default_provider")]
1166    pub provider: String,
1167
1168    /// Default model assignments (used when features.provider_model_ref is enabled).
1169    #[serde(default, skip_serializing_if = "Option::is_none")]
1170    pub defaults: Option<DefaultsConfig>,
1171
1172    /// Provider-specific configurations (legacy, single-instance per type).
1173    #[serde(default)]
1174    pub providers: ProviderConfigs,
1175
1176    /// Multi-instance provider configurations keyed by instance id.
1177    ///
1178    /// When `provider_instances` is non-empty, the registry and router prefer
1179    /// instance ids as routing keys. Legacy `providers` / `provider` fields are
1180    /// still supported for backward compatibility; see
1181    /// [`Config::synthesize_legacy_instances`].
1182    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1183    pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1184
1185    /// The default provider instance id used when a request does not specify one.
1186    ///
1187    /// When set, this takes precedence over the legacy `provider` field.
1188    #[serde(default, skip_serializing_if = "Option::is_none")]
1189    pub default_provider_instance: Option<String>,
1190
1191    /// HTTP server configuration
1192    #[serde(default)]
1193    pub server: ServerConfig,
1194
1195    /// Global keyword masking configuration.
1196    ///
1197    /// Previously persisted in `keyword_masking.json` (now unified into `config.json`).
1198    #[serde(default)]
1199    pub keyword_masking: KeywordMaskingConfig,
1200
1201    /// Anthropic model mapping configuration.
1202    ///
1203    /// Previously persisted in `anthropic-model-mapping.json` (now unified into `config.json`).
1204    #[serde(default)]
1205    pub anthropic_model_mapping: AnthropicModelMapping,
1206
1207    /// Gemini model mapping configuration.
1208    ///
1209    /// Previously persisted in `gemini-model-mapping.json` (now unified into `config.json`).
1210    #[serde(default)]
1211    pub gemini_model_mapping: GeminiModelMapping,
1212
1213    /// Request preflight hooks.
1214    ///
1215    /// These hooks can inspect and rewrite outgoing requests before they are sent upstream
1216    /// (e.g. image fallback behavior for text-only models).
1217    #[serde(default)]
1218    pub hooks: HooksConfig,
1219
1220    /// Global tool toggles.
1221    ///
1222    /// Any tool listed in `disabled` is omitted from the tool schemas sent to the LLM.
1223    #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1224    pub tools: ToolsConfig,
1225
1226    /// Global skill toggles.
1227    ///
1228    /// Any skill listed in `disabled` is excluded from skill context construction and
1229    /// cannot be loaded through the skill runtime tools.
1230    #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1231    pub skills: SkillsConfig,
1232
1233    /// User-managed environment variables injected into Bash tool processes.
1234    ///
1235    /// Secret entries are encrypted at rest; plaintext values are hydrated in memory.
1236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1237    pub env_vars: Vec<EnvVarEntry>,
1238
1239    /// Default work area used when a session has no explicit active workspace.
1240    #[serde(default, skip_serializing_if = "Option::is_none")]
1241    pub default_work_area: Option<DefaultWorkAreaConfig>,
1242
1243    /// Access control / password gate configuration.
1244    #[serde(default, skip_serializing_if = "Option::is_none")]
1245    pub access_control: Option<AccessControlConfig>,
1246
1247    /// Feature flags for incremental rollout.
1248    #[serde(default)]
1249    pub features: FeatureFlags,
1250
1251    /// Memory/background summarization settings.
1252    #[serde(default, skip_serializing_if = "Option::is_none")]
1253    pub memory: Option<MemoryConfig>,
1254
1255    /// Sub-agent execution settings.
1256    ///
1257    /// Sub-agents ALWAYS run as independent actor subprocesses (crash isolation,
1258    /// true parallelism) — the in-process runtime was removed, so there is no
1259    /// `runtime` toggle (a stray `runtime`/`overrides` key in an old config is
1260    /// silently ignored). Most users need nothing here; the fields below
1261    /// (`max_concurrent`, `broker`, remote/schedulable placements) are advanced.
1262    #[serde(default)]
1263    pub subagents: SubagentsConfig,
1264
1265    /// Config-level default per-run token/tool-call/subagent budget (issue
1266    /// #221). `None` fields are unlimited. A per-request `ExecuteRequest`
1267    /// override may only tighten these ceilings, never loosen them; see
1268    /// [`RunBudgetConfig::merged_with_override`].
1269    #[serde(default)]
1270    pub run_budget: RunBudgetConfig,
1271
1272    /// Remote Cluster Fabric: operator-managed nodes & clusters for deploying
1273    /// `broker-agent` workers locally or over SSH. Additive/back-compat: absent
1274    /// ⇒ empty. SSH secrets are encrypted at rest (see [`crate::cluster_fabric`]).
1275    #[serde(
1276        default,
1277        skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
1278    )]
1279    pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
1280
1281    /// MCP server configuration.
1282    ///
1283    /// Previously persisted in `mcp.json` (now unified into `config.json`).
1284    // On disk we use the mainstream `mcpServers` key (matching Claude Desktop / MCP ecosystem
1285    // conventions). We still accept the legacy `mcp` key for backward compatibility.
1286    #[serde(default, rename = "mcpServers", alias = "mcp")]
1287    pub mcp: bamboo_domain::mcp_config::McpConfig,
1288
1289    /// Notification delivery channels (desktop + push-relay services).
1290    /// Secrets (ntfy token, Bark device key) are encrypted at rest — see
1291    /// [`Config::hydrate_notifications_from_encrypted`] /
1292    /// [`Config::refresh_notifications_encrypted`].
1293    #[serde(default)]
1294    pub notifications: NotificationsConfig,
1295
1296    /// bamboo-connect IM-platform bridges (Telegram first, #452 / epic #447).
1297    /// Secrets (each platform's `token`) are encrypted at rest — see
1298    /// [`Config::hydrate_connect_platform_tokens_from_encrypted`] /
1299    /// [`Config::refresh_connect_platform_tokens_encrypted`].
1300    #[serde(default, skip_serializing_if = "connect_config_is_empty")]
1301    pub connect: ConnectConfig,
1302
1303    /// Plugin URL-install source-trust policy (host allowlist + ed25519
1304    /// publisher keys). See [`PluginTrustConfig`]'s docs for the three-layer
1305    /// model this stacks with the checksum layer.
1306    #[serde(default)]
1307    pub plugin_trust: PluginTrustConfig,
1308
1309    /// Extension fields stored at the root of `config.json`.
1310    ///
1311    /// This keeps the config forward-compatible and allows unrelated subsystems
1312    /// (e.g. setup UI state) to persist their own keys without getting dropped by
1313    /// typed (de)serialization.
1314    #[serde(default, flatten)]
1315    pub extra: BTreeMap<String, Value>,
1316
1317    /// In-memory-only marker set when this `Config` was recovered from a
1318    /// corrupt `config.json` at load time (salvage / `.bak` / defaults) and
1319    /// has not yet been confirmed. Never persisted (`#[serde(skip)]`), so
1320    /// every clean load starts at `None` and a fresh process only ever sees
1321    /// it populated right after `Config::from_data_dir` hit corruption.
1322    ///
1323    /// [`Config::save_to_dir`] refuses to overwrite `config.json` while this
1324    /// is `Some` and not `confirmed` — the corrupt original stays exactly as
1325    /// it was on disk until [`Config::confirm_recovery`] (or
1326    /// [`Config::confirm_recovery_and_save_to_dir`]) is called. #153.
1327    #[serde(skip)]
1328    pub recovery_status: Option<ConfigRecoveryStatus>,
1329}
1330
1331/// Where a [`ConfigRecoveryStatus`]'s recovered values came from. #153.
1332#[derive(Debug, Clone, PartialEq, Serialize)]
1333#[serde(tag = "kind", rename_all = "snake_case")]
1334pub enum ConfigRecoverySource {
1335    /// Field-by-field salvage from the corrupt file itself
1336    /// ([`Config::salvage_partial`]); `fields` lists the top-level keys that
1337    /// were recovered from the corrupt document (any other field fell back to
1338    /// the backup/default baseline instead).
1339    Salvaged { fields: Vec<String> },
1340    /// Recovered wholesale from a `config.json.bak[.N]` generation
1341    /// (`generation` 0 == `.bak`, 1 == `.bak.1`, …).
1342    Backup { generation: usize },
1343    /// No usable salvage or backup; fell back to built-in defaults.
1344    Defaults,
1345}
1346
1347/// Describes a pending config-corruption recovery (#153, following on from
1348/// #37/#135's quarantine + salvage/backup chain): `config.json` failed to
1349/// parse at load time, the corrupt original was quarantined (copied aside,
1350/// not deleted) to `quarantine_path`, and the owning [`Config`] holds the
1351/// recovered in-memory state instead.
1352///
1353/// [`Config::save_to_dir`] refuses to overwrite `config.json` while
1354/// `confirmed` is `false`, so a user who would rather hand-fix the original
1355/// isn't surprised by an automatic overwrite on the next save. Call
1356/// [`Config::confirm_recovery`] (or [`Config::confirm_recovery_and_save_to_dir`])
1357/// to allow the next save through.
1358#[derive(Debug, Clone, PartialEq, Serialize)]
1359pub struct ConfigRecoveryStatus {
1360    /// Where the recovered values came from.
1361    pub source: ConfigRecoverySource,
1362    /// Absolute path of the preserved copy of the corrupt original
1363    /// (`config.json.corrupted.<nanos>`), or `None` if even the quarantine
1364    /// copy failed (the corrupt original still remains in place at
1365    /// `config.json` itself either way — quarantining copies, it doesn't
1366    /// move — so the guard below still applies).
1367    pub quarantine_path: Option<PathBuf>,
1368    /// Set `true` once the user has explicitly confirmed the recovery; only
1369    /// then may `save_to_dir` persist over the original `config.json`.
1370    pub confirmed: bool,
1371}
1372
1373/// Container for provider-specific configurations
1374///
1375/// Each field is optional, allowing users to configure only the providers they need.
1376#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1377pub struct ProviderConfigs {
1378    /// OpenAI provider configuration
1379    #[serde(skip_serializing_if = "Option::is_none")]
1380    pub openai: Option<OpenAIConfig>,
1381    /// Anthropic provider configuration
1382    #[serde(skip_serializing_if = "Option::is_none")]
1383    pub anthropic: Option<AnthropicConfig>,
1384    /// Google Gemini provider configuration
1385    #[serde(skip_serializing_if = "Option::is_none")]
1386    pub gemini: Option<GeminiConfig>,
1387    /// GitHub Copilot provider configuration
1388    #[serde(skip_serializing_if = "Option::is_none")]
1389    pub copilot: Option<CopilotConfig>,
1390    /// Bodhi proxy provider configuration
1391    #[serde(skip_serializing_if = "Option::is_none")]
1392    pub bodhi: Option<BodhiConfig>,
1393
1394    /// Preserve unknown provider keys (forward compatibility).
1395    #[serde(default, flatten)]
1396    pub extra: BTreeMap<String, Value>,
1397}
1398
1399/// Feature flags for incremental rollout of new subsystems.
1400#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1401pub struct FeatureFlags {
1402    /// Enable the ProviderModelRef system (multi-provider + unified model selection).
1403    #[serde(default)]
1404    pub provider_model_ref: bool,
1405    /// Enable MiniLoop-based complexity evaluation and dynamic per-round model switching.
1406    #[serde(default)]
1407    pub dynamic_model_routing: bool,
1408}
1409
1410/// Default model assignments for specific capabilities.
1411///
1412/// Used when `features.provider_model_ref` is enabled.
1413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1414pub struct DefaultsConfig {
1415    pub chat: bamboo_domain::ProviderModelRef,
1416    #[serde(default, skip_serializing_if = "Option::is_none")]
1417    pub fast: Option<bamboo_domain::ProviderModelRef>,
1418    #[serde(default, skip_serializing_if = "Option::is_none")]
1419    pub task_summary: Option<bamboo_domain::ProviderModelRef>,
1420    #[serde(default, skip_serializing_if = "Option::is_none")]
1421    pub vision: Option<bamboo_domain::ProviderModelRef>,
1422    #[serde(default, skip_serializing_if = "Option::is_none")]
1423    pub memory_background: Option<bamboo_domain::ProviderModelRef>,
1424    /// Model for planning/coordination tasks (task decomposition, architecture).
1425    /// Falls back to `chat` when unset.
1426    #[serde(default, skip_serializing_if = "Option::is_none")]
1427    pub planning: Option<bamboo_domain::ProviderModelRef>,
1428    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
1429    /// Falls back to `fast` when unset.
1430    #[serde(default, skip_serializing_if = "Option::is_none")]
1431    pub search: Option<bamboo_domain::ProviderModelRef>,
1432    /// Model for code review tasks.
1433    /// Falls back to `chat` when unset.
1434    #[serde(default, skip_serializing_if = "Option::is_none")]
1435    pub code_review: Option<bamboo_domain::ProviderModelRef>,
1436    /// Default model for child SubAgent runs.
1437    /// Falls back to `fast`, then `chat` when unset.
1438    #[serde(
1439        default,
1440        skip_serializing_if = "Option::is_none",
1441        alias = "sub_session"
1442    )]
1443    pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
1444    /// Per-subagent-type model overrides.
1445    /// Key = subagent_type (e.g. "researcher", "coder"), Value = ProviderModelRef.
1446    /// Falls back to `chat` when no match is found for a given type.
1447    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1448    pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
1449}
1450
1451/// Request hook configuration.
1452#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1453pub struct HooksConfig {
1454    /// Image fallback behavior for OpenAI-compatible requests (chat/responses).
1455    #[serde(default)]
1456    pub image_fallback: ImageFallbackHookConfig,
1457}
1458
1459/// Request override configuration for provider-specific HTTP behavior.
1460///
1461/// Overrides are merged in this order (later wins):
1462/// 1. `common`
1463/// 2. `endpoints[endpoint]`
1464/// 3. matching `rules` (sorted by specificity)
1465#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1466pub struct RequestOverridesConfig {
1467    /// Overrides applied to all endpoints.
1468    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
1469    pub common: RequestScopeOverride,
1470    /// Endpoint-specific overrides (`chat_completions`, `responses`, `messages`, etc.).
1471    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1472    pub endpoints: BTreeMap<String, RequestScopeOverride>,
1473    /// Model-conditional overrides.
1474    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1475    pub rules: Vec<ModelRequestRule>,
1476}
1477
1478/// A conditional override rule matching a model pattern.
1479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1480pub struct ModelRequestRule {
1481    /// Model pattern (exact: `gpt-4o`, prefix wildcard: `gpt-5*`).
1482    pub model_pattern: String,
1483    /// Optional endpoint constraint.
1484    #[serde(default, skip_serializing_if = "Option::is_none")]
1485    pub endpoint: Option<String>,
1486    /// Overrides applied when this rule matches.
1487    #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
1488    pub scope: RequestScopeOverride,
1489}
1490
1491/// Request overrides applied in a specific scope.
1492#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1493pub struct RequestScopeOverride {
1494    /// Extra or overridden HTTP headers.
1495    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1496    pub headers: BTreeMap<String, TemplateExpr>,
1497    /// JSON body patch operations.
1498    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1499    pub body_patch: Vec<BodyPatch>,
1500}
1501
1502impl RequestScopeOverride {
1503    pub fn is_empty(&self) -> bool {
1504        self.headers.is_empty() && self.body_patch.is_empty()
1505    }
1506}
1507
1508/// Body patch operation.
1509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1510pub struct BodyPatch {
1511    /// Target path (`foo.bar.0` or `/foo/bar/0`).
1512    pub path: String,
1513    /// Operation type.
1514    #[serde(default)]
1515    pub op: BodyPatchOp,
1516    /// Value for `set` operation.
1517    #[serde(default, skip_serializing_if = "Option::is_none")]
1518    pub value: Option<PatchValue>,
1519}
1520
1521/// Supported body patch operations.
1522#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1523#[serde(rename_all = "snake_case")]
1524pub enum BodyPatchOp {
1525    #[default]
1526    Set,
1527    Remove,
1528}
1529
1530/// Body patch value: either a template expression or a raw JSON value.
1531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1532#[serde(untagged)]
1533pub enum PatchValue {
1534    Template(TemplateExpr),
1535    Json(Value),
1536}
1537
1538/// String template expression used by headers/body patch values.
1539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1540#[serde(untagged)]
1541pub enum TemplateExpr {
1542    /// Shorthand literal value.
1543    Literal(String),
1544    /// Structured template expression.
1545    Structured(TemplateExprSpec),
1546}
1547
1548/// Structured template expression.
1549#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1550#[serde(tag = "type", rename_all = "snake_case")]
1551pub enum TemplateExprSpec {
1552    /// Literal string value.
1553    Literal { value: String },
1554    /// Reference a value from Bamboo env vars.
1555    EnvRef {
1556        name: String,
1557        #[serde(default, skip_serializing_if = "Option::is_none")]
1558        fallback: Option<String>,
1559    },
1560    /// Generate a runtime value.
1561    Generated { generator: GeneratedValue },
1562    /// Format string with placeholders (`{env:NAME}`, `{uuid}`, `{unix_ms}`).
1563    Format { template: String },
1564}
1565
1566/// Supported generated value kinds.
1567#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1568#[serde(rename_all = "snake_case")]
1569pub enum GeneratedValue {
1570    Uuid,
1571    UnixMs,
1572}
1573
1574/// Global tool toggle configuration.
1575#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1576pub struct ToolsConfig {
1577    /// Tool names that are disabled globally.
1578    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1579    pub disabled: Vec<String>,
1580}
1581
1582impl ToolsConfig {
1583    fn is_empty(&self) -> bool {
1584        self.disabled.is_empty()
1585    }
1586}
1587
1588/// Global skill toggle configuration.
1589#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1590pub struct SkillsConfig {
1591    /// Skill IDs that are disabled globally.
1592    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1593    pub disabled: Vec<String>,
1594}
1595
1596impl SkillsConfig {
1597    fn is_empty(&self) -> bool {
1598        self.disabled.is_empty()
1599    }
1600}
1601
1602/// When a request contains image parts but the effective provider path is text-only,
1603/// we can either:
1604/// - error fast (preferred for strict setups), or
1605/// - degrade gracefully by replacing images with a placeholder text.
1606#[derive(Debug, Clone, Serialize, Deserialize)]
1607pub struct ImageFallbackHookConfig {
1608    #[serde(default = "default_true_hooks")]
1609    pub enabled: bool,
1610
1611    /// "placeholder" (default) or "error"
1612    #[serde(default = "default_image_fallback_mode")]
1613    pub mode: String,
1614}
1615
1616impl Default for ImageFallbackHookConfig {
1617    fn default() -> Self {
1618        Self {
1619            enabled: default_true_hooks(),
1620            mode: default_image_fallback_mode(),
1621        }
1622    }
1623}
1624
1625fn default_image_fallback_mode() -> String {
1626    "placeholder".to_string()
1627}
1628
1629fn default_true_hooks() -> bool {
1630    // Default to disabled so image inputs are preserved unless the user explicitly
1631    // opts into fallback rewriting (placeholder/error/ocr).
1632    false
1633}
1634
1635/// OpenAI provider configuration
1636///
1637/// # Example
1638///
1639/// ```json
1640/// "openai": {
1641///   "api_key": "sk-...",
1642///   "base_url": "https://api.openai.com/v1",
1643///   "model": "gpt-4"
1644/// }
1645/// ```
1646#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1647pub struct OpenAIConfig {
1648    /// OpenAI API key (plaintext, in-memory only).
1649    ///
1650    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
1651    #[serde(default, skip_serializing)]
1652    pub api_key: String,
1653    /// Encrypted OpenAI API key (nonce:ciphertext).
1654    #[serde(default, skip_serializing_if = "Option::is_none")]
1655    pub api_key_encrypted: Option<String>,
1656    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
1657    /// Such keys are runtime-only and MUST NOT be re-encrypted into
1658    /// `api_key_encrypted` on save (that would bake the secret into
1659    /// config.json). Not (de)serialized. (#253)
1660    #[serde(skip)]
1661    pub api_key_from_env: bool,
1662    /// Custom API base URL (for Azure or self-hosted deployments)
1663    #[serde(skip_serializing_if = "Option::is_none")]
1664    pub base_url: Option<String>,
1665    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
1666    #[serde(skip_serializing_if = "Option::is_none")]
1667    pub model: Option<String>,
1668    /// Fast/cheap model for lightweight tasks (title generation and summarization).
1669    /// Falls back to `model` when not set.
1670    #[serde(default, skip_serializing_if = "Option::is_none")]
1671    pub fast_model: Option<String>,
1672    /// Vision-capable model for image understanding tasks.
1673    /// Falls back to `model` when not set.
1674    #[serde(default, skip_serializing_if = "Option::is_none")]
1675    pub vision_model: Option<String>,
1676    /// Default reasoning effort for OpenAI requests.
1677    #[serde(skip_serializing_if = "Option::is_none")]
1678    pub reasoning_effort: Option<ReasoningEffort>,
1679
1680    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
1681    ///
1682    /// Example:
1683    /// ```json
1684    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
1685    /// ```
1686    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1687    pub responses_only_models: Vec<String>,
1688    /// Optional request overrides (headers/body patches/model rules).
1689    #[serde(default, skip_serializing_if = "Option::is_none")]
1690    pub request_overrides: Option<RequestOverridesConfig>,
1691
1692    /// Preserve unknown keys under `providers.openai`.
1693    #[serde(default, flatten)]
1694    pub extra: BTreeMap<String, Value>,
1695}
1696
1697/// Anthropic provider configuration
1698///
1699/// # Example
1700///
1701/// ```json
1702/// "anthropic": {
1703///   "api_key": "sk-ant-...",
1704///   "model": "claude-3-5-sonnet-20241022",
1705///   "max_tokens": 4096
1706/// }
1707/// ```
1708#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1709pub struct AnthropicConfig {
1710    /// Anthropic API key (plaintext, in-memory only).
1711    ///
1712    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
1713    #[serde(default, skip_serializing)]
1714    pub api_key: String,
1715    /// Encrypted Anthropic API key (nonce:ciphertext).
1716    #[serde(default, skip_serializing_if = "Option::is_none")]
1717    pub api_key_encrypted: Option<String>,
1718    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
1719    /// Such keys are runtime-only and MUST NOT be re-encrypted into
1720    /// `api_key_encrypted` on save (that would bake the secret into
1721    /// config.json). Not (de)serialized. (#253)
1722    #[serde(skip)]
1723    pub api_key_from_env: bool,
1724    /// Custom API base URL
1725    #[serde(skip_serializing_if = "Option::is_none")]
1726    pub base_url: Option<String>,
1727    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
1728    #[serde(skip_serializing_if = "Option::is_none")]
1729    pub model: Option<String>,
1730    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
1731    /// Falls back to `model` when not set.
1732    #[serde(default, skip_serializing_if = "Option::is_none")]
1733    pub fast_model: Option<String>,
1734    /// Vision-capable model for image understanding tasks.
1735    /// Falls back to `model` when not set.
1736    #[serde(default, skip_serializing_if = "Option::is_none")]
1737    pub vision_model: Option<String>,
1738    /// Maximum tokens in model response
1739    #[serde(skip_serializing_if = "Option::is_none")]
1740    pub max_tokens: Option<u32>,
1741    /// Default reasoning effort for Anthropic requests.
1742    #[serde(skip_serializing_if = "Option::is_none")]
1743    pub reasoning_effort: Option<ReasoningEffort>,
1744    /// Optional request overrides (headers/body patches/model rules).
1745    #[serde(default, skip_serializing_if = "Option::is_none")]
1746    pub request_overrides: Option<RequestOverridesConfig>,
1747
1748    /// Unconditionally replay a prior turn's `reasoning` as a `thinking`
1749    /// content block, regardless of whether bamboo captured a valid signature
1750    /// for it (issue #520).
1751    ///
1752    /// Defaults to `false`/absent, which is REQUIRED for real Anthropic: it
1753    /// requires `thinking` input blocks to carry a signature it minted itself,
1754    /// and bamboo never captures one, so an unconditionally-replayed block is
1755    /// always rejected with a 400 (either because it's foreign — minted by a
1756    /// different provider after a mid-session model switch — or because it's
1757    /// an unsigned copy of Claude's own prior turn).
1758    ///
1759    /// Set this to `true` only when pointing `base_url` at an
1760    /// Anthropic-COMPATIBLE upstream (e.g. GLM's `/anthropic` endpoint) that
1761    /// has the opposite contract: it requires the `thinking` block to be
1762    /// present whenever thinking is enabled, but never validates its
1763    /// signature.
1764    #[serde(default, skip_serializing_if = "Option::is_none")]
1765    pub thinking_replay_always: Option<bool>,
1766
1767    /// Preserve unknown keys under `providers.anthropic`.
1768    #[serde(default, flatten)]
1769    pub extra: BTreeMap<String, Value>,
1770}
1771
1772/// Google Gemini provider configuration
1773///
1774/// # Example
1775///
1776/// ```json
1777/// "gemini": {
1778///   "api_key": "AIza...",
1779///   "model": "gemini-2.0-flash-exp"
1780/// }
1781/// ```
1782#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1783pub struct GeminiConfig {
1784    /// Google AI API key (plaintext, in-memory only).
1785    ///
1786    /// On disk this is stored as `api_key_encrypted` and hydrated on load.
1787    #[serde(default, skip_serializing)]
1788    pub api_key: String,
1789    /// Encrypted Google AI API key (nonce:ciphertext).
1790    #[serde(default, skip_serializing_if = "Option::is_none")]
1791    pub api_key_encrypted: Option<String>,
1792    /// True when `api_key` was supplied via a `BAMBOO_*_API_KEY` env var.
1793    /// Such keys are runtime-only and MUST NOT be re-encrypted into
1794    /// `api_key_encrypted` on save (that would bake the secret into
1795    /// config.json). Not (de)serialized. (#253)
1796    #[serde(skip)]
1797    pub api_key_from_env: bool,
1798    /// Custom API base URL
1799    #[serde(skip_serializing_if = "Option::is_none")]
1800    pub base_url: Option<String>,
1801    /// Default model to use (e.g., "gemini-2.0-flash-exp")
1802    #[serde(skip_serializing_if = "Option::is_none")]
1803    pub model: Option<String>,
1804    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
1805    /// Falls back to `model` when not set.
1806    #[serde(default, skip_serializing_if = "Option::is_none")]
1807    pub fast_model: Option<String>,
1808    /// Vision-capable model for image understanding tasks.
1809    /// Falls back to `model` when not set.
1810    #[serde(default, skip_serializing_if = "Option::is_none")]
1811    pub vision_model: Option<String>,
1812    /// Default reasoning effort for Gemini requests.
1813    #[serde(skip_serializing_if = "Option::is_none")]
1814    pub reasoning_effort: Option<ReasoningEffort>,
1815    /// Optional request overrides (headers/body patches/model rules).
1816    #[serde(default, skip_serializing_if = "Option::is_none")]
1817    pub request_overrides: Option<RequestOverridesConfig>,
1818
1819    /// Preserve unknown keys under `providers.gemini`.
1820    #[serde(default, flatten)]
1821    pub extra: BTreeMap<String, Value>,
1822}
1823
1824/// GitHub Copilot provider configuration
1825///
1826/// # Example
1827///
1828/// ```json
1829/// "copilot": {
1830///   "enabled": true,
1831///   "headless_auth": false,
1832///   "model": "gpt-4o"
1833/// }
1834/// ```
1835#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1836pub struct CopilotConfig {
1837    /// Whether Copilot provider is enabled
1838    #[serde(default)]
1839    pub enabled: bool,
1840    /// Print login URL to console instead of opening browser
1841    #[serde(default)]
1842    pub headless_auth: bool,
1843    /// Default model to use for Copilot (used when clients request the "default" model)
1844    #[serde(skip_serializing_if = "Option::is_none")]
1845    pub model: Option<String>,
1846    /// Fast/cheap model for lightweight tasks (title generation, mermaid fix, summarization).
1847    /// Falls back to `model` when not set.
1848    #[serde(default, skip_serializing_if = "Option::is_none")]
1849    pub fast_model: Option<String>,
1850    /// Vision-capable model for image understanding tasks.
1851    /// Falls back to `model` when not set.
1852    #[serde(default, skip_serializing_if = "Option::is_none")]
1853    pub vision_model: Option<String>,
1854    /// Default reasoning effort for Copilot requests.
1855    #[serde(skip_serializing_if = "Option::is_none")]
1856    pub reasoning_effort: Option<ReasoningEffort>,
1857
1858    /// Models that must use the OpenAI Responses API upstream (instead of chat/completions).
1859    ///
1860    /// This is useful for newer Copilot models that only support Responses-style requests.
1861    ///
1862    /// Example:
1863    /// ```json
1864    /// "responses_only_models": ["gpt-5.3-codex", "gpt-5*"]
1865    /// ```
1866    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1867    pub responses_only_models: Vec<String>,
1868    /// Optional request overrides (headers/body patches/model rules).
1869    #[serde(default, skip_serializing_if = "Option::is_none")]
1870    pub request_overrides: Option<RequestOverridesConfig>,
1871
1872    /// Preserve unknown keys under `providers.copilot`.
1873    #[serde(default, flatten)]
1874    pub extra: BTreeMap<String, Value>,
1875}
1876
1877/// Bodhi proxy provider configuration.
1878///
1879/// Routes LLM requests through a bodhi-server instance so that raw provider
1880/// API keys never reach the client.
1881#[derive(Debug, Clone, Serialize, Deserialize)]
1882pub struct BodhiConfig {
1883    /// Bodhi server API key (e.g. "bhi_sk_xxx").  In-memory only.
1884    #[serde(default, skip_serializing)]
1885    pub api_key: String,
1886    /// Encrypted form of the API key stored on disk.
1887    #[serde(default, skip_serializing_if = "Option::is_none")]
1888    pub api_key_encrypted: Option<String>,
1889    /// Bodhi server base URL.
1890    #[serde(skip_serializing_if = "Option::is_none")]
1891    pub base_url: Option<String>,
1892    /// Which upstream provider to route through bodhi ("openai", "anthropic", "gemini").
1893    #[serde(skip_serializing_if = "Option::is_none")]
1894    pub target_provider: Option<String>,
1895    /// Default reasoning effort.
1896    #[serde(skip_serializing_if = "Option::is_none")]
1897    pub reasoning_effort: Option<ReasoningEffort>,
1898
1899    /// Preserve unknown keys.
1900    #[serde(default, flatten)]
1901    pub extra: BTreeMap<String, Value>,
1902}
1903
1904/// Returns the default provider name ("anthropic")
1905fn default_provider() -> String {
1906    "anthropic".to_string()
1907}
1908
1909// ─── Provider Instance Configuration ──────────────────────────────────
1910
1911/// Configuration for a single provider instance.
1912///
1913/// Multiple instances of the same provider type (e.g. two OpenAI accounts)
1914/// can coexist. Each instance is identified by a stable `instance_id` that
1915/// is used as the routing key in [`ProviderModelRef::provider`] and the
1916/// provider registry.
1917///
1918/// # Example (config.json)
1919///
1920/// ```json
1921/// {
1922///   "provider_instances": {
1923///     "openai-work": {
1924///       "provider_type": "openai",
1925///       "label": "OpenAI (Work)",
1926///       "api_key": "sk-...",
1927///       "model": "gpt-4o"
1928///     },
1929///     "openai-personal": {
1930///       "provider_type": "openai",
1931///       "label": "OpenAI (Personal)",
1932///       "api_key": "sk-...",
1933///       "base_url": "https://api.openai.com/v1",
1934///       "model": "gpt-4o-mini"
1935///     }
1936///   },
1937///   "default_provider_instance": "openai-work"
1938/// }
1939/// ```
1940#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1941pub struct ProviderInstanceConfig {
1942    /// Which provider backend this instance targets.
1943    ///
1944    /// Must be one of [`AVAILABLE_PROVIDERS`]: `"openai"`, `"anthropic"`,
1945    /// `"gemini"`, `"copilot"`, `"bodhi"`.
1946    pub provider_type: String,
1947
1948    /// Human-readable label shown in the UI / catalog.
1949    #[serde(default, skip_serializing_if = "Option::is_none")]
1950    pub label: Option<String>,
1951
1952    /// API key (plaintext in memory, encrypted at rest via `api_key_encrypted`).
1953    #[serde(default, skip_serializing)]
1954    pub api_key: String,
1955
1956    /// Encrypted API key (nonce:ciphertext). Written to disk; decrypted into
1957    /// `api_key` on load.
1958    #[serde(default, skip_serializing_if = "Option::is_none")]
1959    pub api_key_encrypted: Option<String>,
1960
1961    /// Custom base URL override.
1962    #[serde(default, skip_serializing_if = "Option::is_none")]
1963    pub base_url: Option<String>,
1964
1965    /// Default chat model for this instance.
1966    #[serde(default, skip_serializing_if = "Option::is_none")]
1967    pub model: Option<String>,
1968
1969    /// Fast/cheap model for lightweight tasks.
1970    #[serde(default, skip_serializing_if = "Option::is_none")]
1971    pub fast_model: Option<String>,
1972
1973    /// Vision-capable model.
1974    #[serde(default, skip_serializing_if = "Option::is_none")]
1975    pub vision_model: Option<String>,
1976
1977    /// Default reasoning effort.
1978    #[serde(default, skip_serializing_if = "Option::is_none")]
1979    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
1980
1981    /// Models that must use the Responses API upstream (OpenAI only).
1982    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1983    pub responses_only_models: Vec<String>,
1984
1985    /// Optional request overrides (headers/body patches/model rules).
1986    #[serde(default, skip_serializing_if = "Option::is_none")]
1987    pub request_overrides: Option<RequestOverridesConfig>,
1988
1989    /// Whether this instance is enabled. Disabled instances are skipped
1990    /// during registry construction.
1991    #[serde(default = "default_true")]
1992    pub enabled: bool,
1993
1994    /// Provider-type-specific extra fields preserved through (de)serialization.
1995    #[serde(default, flatten)]
1996    pub extra: BTreeMap<String, Value>,
1997}
1998
1999fn default_true() -> bool {
2000    true
2001}
2002
2003/// Returns the default server port (9562)
2004fn default_port() -> u16 {
2005    9562
2006}
2007
2008/// Returns the default bind address (127.0.0.1)
2009fn default_bind() -> String {
2010    "127.0.0.1".to_string()
2011}
2012
2013/// Returns the default worker count (10)
2014fn default_workers() -> usize {
2015    10
2016}
2017
2018/// Returns the default data directory (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
2019fn default_data_dir() -> PathBuf {
2020    super::paths::bamboo_dir()
2021}
2022
2023/// HTTP server configuration
2024#[derive(Debug, Clone, Serialize, Deserialize)]
2025pub struct ServerConfig {
2026    /// Port to listen on
2027    #[serde(default = "default_port")]
2028    pub port: u16,
2029
2030    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
2031    #[serde(default = "default_bind")]
2032    pub bind: String,
2033
2034    /// Static files directory (for Docker mode)
2035    pub static_dir: Option<PathBuf>,
2036
2037    /// Worker count for Actix-web
2038    #[serde(default = "default_workers")]
2039    pub workers: usize,
2040
2041    /// v2 (API v2 transport, #181): optional TLS termination config. When both
2042    /// `cert_file` and `key_file` are given, bamboo terminates TLS itself
2043    /// (rustls, no reverse proxy) and serves `https://` — intended for the
2044    /// public `0.0.0.0` face. When absent, the server keeps the plain `.bind()`
2045    /// / `.listen()` path unchanged (desktop loopback stays plaintext). Missing
2046    /// or unparseable cert/key files are fail-fast at startup, never a silent
2047    /// downgrade to plaintext.
2048    #[serde(default, skip_serializing_if = "Option::is_none")]
2049    pub tls: Option<TlsConfig>,
2050
2051    /// Preserve unknown keys under `server`.
2052    #[serde(default, flatten)]
2053    pub extra: BTreeMap<String, Value>,
2054}
2055
2056impl Default for ServerConfig {
2057    fn default() -> Self {
2058        Self {
2059            port: default_port(),
2060            bind: default_bind(),
2061            static_dir: None,
2062            workers: default_workers(),
2063            tls: None,
2064            extra: BTreeMap::new(),
2065        }
2066    }
2067}
2068
2069/// Manual TLS certificate configuration (current stage; ACME deferred).
2070///
2071/// Both fields point at PEM files: `cert_file` is the full certificate chain
2072/// (leaf → intermediates → root), `key_file` is the matching private key
2073/// (PKCS#8 or RSA). See `docs/api-v2-transport.md` §3.
2074#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2075pub struct TlsConfig {
2076    /// PEM certificate chain (leaf → intermediates → root).
2077    pub cert_file: PathBuf,
2078    /// PEM private key (PKCS#8 or RSA).
2079    pub key_file: PathBuf,
2080}
2081
2082/// Proxy authentication credentials
2083#[derive(Debug, Clone, Serialize, Deserialize)]
2084pub struct ProxyAuth {
2085    /// Proxy username
2086    pub username: String,
2087    /// Proxy password
2088    pub password: String,
2089}
2090
2091/// Parse a boolean value from environment variable strings
2092///
2093/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
2094fn parse_bool_env(value: &str) -> bool {
2095    matches!(
2096        value.trim().to_ascii_lowercase().as_str(),
2097        "1" | "true" | "yes" | "y" | "on"
2098    )
2099}
2100
2101fn expand_user_path(value: &str) -> PathBuf {
2102    let trimmed = value.trim();
2103    if let Some(rest) = trimmed.strip_prefix("~/") {
2104        if let Some(home) = dirs::home_dir() {
2105            return home.join(rest);
2106        }
2107    }
2108    PathBuf::from(trimmed)
2109}
2110
2111impl Default for Config {
2112    fn default() -> Self {
2113        // In-memory defaults ONLY. `default()` must not touch the filesystem or
2114        // environment: it was delegating to `new()` → `from_data_dir(None)`,
2115        // which read config.json from disk, applied BAMBOO_* env overrides, and
2116        // published to the global env-var cache. That made every `..Default::
2117        // default()` struct-update and every test silently disk-dependent and
2118        // let non-server callers clobber the server's in-memory config cache.
2119        // `create_default()` is the pure in-memory constructor; disk loading is
2120        // the explicit job of `new()` / `from_data_dir()`. #38.
2121        Self::create_default()
2122    }
2123}
2124
2125/// Prompt-safe snapshot of configured env vars.
2126#[derive(Debug, Clone, PartialEq, Eq)]
2127pub struct PromptSafeEnvVarEntry {
2128    pub name: String,
2129    pub secret: bool,
2130    pub description: Option<String>,
2131}
2132
2133/// Global cache of user-managed env vars for injection into child processes.
2134///
2135/// Updated whenever the config is loaded or reloaded via [`Config::publish_env_vars`].
2136static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
2137
2138static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
2139
2140fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
2141    ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
2142}
2143
2144fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
2145    PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
2146}
2147
2148impl Config {
2149    /// Load configuration from file with environment variable overrides
2150    ///
2151    /// Configuration loading order:
2152    /// 1. Try loading from `config.json` (`{data_dir}/config.json`)
2153    /// 2. Use defaults
2154    /// 3. Apply environment variable overrides (highest priority)
2155    ///
2156    /// # Environment Variables
2157    ///
2158    /// - `BAMBOO_PORT`: Override server port
2159    /// - `BAMBOO_BIND`: Override bind address
2160    /// - `BAMBOO_DATA_DIR`: Override data directory
2161    /// - `BAMBOO_PROVIDER`: Override default provider
2162    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
2163    /// - `BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION`: Override project durable-memory index prompt injection
2164    /// - `BAMBOO_MEMORY_RELEVANT_RECALL`: Override relevant durable-memory recall prompt injection
2165    /// - `BAMBOO_MEMORY_RELEVANT_RECALL_RERANK`: Override model-based relevant recall reranking
2166    /// - `BAMBOO_MEMORY_PROJECT_FIRST_DREAM`: Override project-first Dream prompt behavior
2167    pub fn new() -> Self {
2168        Self::from_data_dir(None)
2169    }
2170
2171    /// Load configuration from a specific data directory.
2172    ///
2173    /// Use [`Config::from_data_dir`] (publishes env vars to the global cache, for
2174    /// the context that OWNS the cache — the server bootstrap) or
2175    /// [`Config::from_data_dir_without_publish`] (for non-owning readers that must
2176    /// not clobber the live cache). #40.
2177    ///
2178    /// * `data_dir` - Optional data directory path. If None, uses default (`BAMBOO_DATA_DIR` or `${HOME}/.bamboo`)
2179    fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
2180        // Determine data_dir early (needed to find config file)
2181        let data_dir = data_dir
2182            .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
2183            .unwrap_or_else(default_data_dir);
2184
2185        let config_path = data_dir.join("config.json");
2186
2187        let mut config = if config_path.exists() {
2188            if let Ok(content) = std::fs::read_to_string(&config_path) {
2189                Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
2190                    // Don't silently discard the user's config on corruption.
2191                    // Quarantine the unparseable file, then recover the MOST recent
2192                    // intent in order: (1) SALVAGE the still-valid fields from the
2193                    // corrupt file (a single bad field shouldn't drop everything),
2194                    // (2) the last-known-good config.json.bak, (3) defaults.
2195                    // #37 / #135. Tag the recovered config with a
2196                    // ConfigRecoveryStatus (unconfirmed) so save_to_dir refuses to
2197                    // overwrite config.json until the caller confirms — the
2198                    // quarantined original stays hand-recoverable until then. #153.
2199                    tracing::warn!(
2200                        "Failed to parse config.json ({}); quarantining it and attempting recovery",
2201                        e
2202                    );
2203                    let quarantine_path = quarantine_corrupt_config(&config_path);
2204                    let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
2205                        .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
2206                        .or_else(|| {
2207                            Self::load_backup(&data_dir).map(|(cfg, generation)| {
2208                                (cfg, ConfigRecoverySource::Backup { generation })
2209                            })
2210                        })
2211                        .unwrap_or_else(|| {
2212                            tracing::warn!(
2213                                "Could not salvage and no usable config.json.bak; using defaults"
2214                            );
2215                            (Self::create_default(), ConfigRecoverySource::Defaults)
2216                        });
2217                    recovered.recovery_status = Some(ConfigRecoveryStatus {
2218                        source,
2219                        quarantine_path,
2220                        confirmed: false,
2221                    });
2222                    recovered
2223                })
2224            } else {
2225                Self::create_default()
2226            }
2227        } else {
2228            Self::create_default()
2229        };
2230
2231        // Decrypt encrypted proxy auth into in-memory plaintext form.
2232        config.hydrate_proxy_auth_from_encrypted();
2233        // Decrypt encrypted provider API keys into in-memory plaintext form.
2234        config.hydrate_provider_api_keys_from_encrypted();
2235        // Decrypt encrypted provider-instance API keys into in-memory plaintext form.
2236        config.hydrate_provider_instance_api_keys_from_encrypted();
2237        // Decrypt encrypted MCP secrets into in-memory plaintext form.
2238        config.hydrate_mcp_secrets_from_encrypted();
2239        // Decrypt encrypted env vars into in-memory plaintext form.
2240        config.hydrate_env_vars_from_encrypted();
2241        // Decrypt encrypted cluster-fabric SSH secrets into in-memory plaintext.
2242        config.hydrate_cluster_fabric_from_encrypted();
2243        // Decrypt the encrypted broker token into in-memory plaintext.
2244        config.hydrate_broker_token_from_encrypted();
2245        // Decrypt encrypted notification-channel secrets into in-memory plaintext.
2246        config.hydrate_notifications_from_encrypted();
2247        // Merge the standalone connect.json (#455) onto `config.connect`,
2248        // migrating a legacy inline `connect` key from config.json (#453
2249        // state) when present. MUST run before the token hydration below so
2250        // it decrypts the post-merge ciphertext, not a stale/legacy copy.
2251        config.merge_connect_config(&data_dir);
2252        // One-time (idempotent) sweep of the rotated config.json.bak[.N]
2253        // generations for a legacy embedded `connect` sub-tree left behind by
2254        // a pre-#455 build (#468, follow-up to #457). Independent of whether
2255        // `merge_connect_config` just migrated the CURRENT config.json above —
2256        // an instance that was already migrated by an earlier run of this
2257        // binary has a clean config.json today but may still carry the
2258        // legacy key in an untouched `.bak`/`.bak.1`/`.bak.2` generation, since
2259        // backup rotation only overwrites those on a fresh SAVE. Runs on every
2260        // load but is cheap and a no-op once every generation has been swept.
2261        scrub_legacy_connect_from_config_backups(&data_dir);
2262        // Decrypt encrypted bamboo-connect platform tokens into in-memory plaintext.
2263        config.hydrate_connect_platform_tokens_from_encrypted();
2264        config.normalize_tool_settings();
2265        config.normalize_skill_settings();
2266        config.normalize_plugin_trust_settings();
2267
2268        // Legacy: `data_dir` is no longer a persisted config field. The data directory is
2269        // derived from runtime (BAMBOO_DATA_DIR or `${HOME}/.bamboo`).
2270        config.extra.remove("data_dir");
2271
2272        // Apply environment variable overrides (highest priority). Skipped by
2273        // one-shot CLI writers (`bamboo init` / `config set`) so transient
2274        // `BAMBOO_*` values are never baked into the persisted config.json.
2275        if apply_env {
2276            config.apply_env_overrides();
2277        }
2278
2279        // Publish env vars to the global cache so Bash tools can inject them —
2280        // ONLY when the caller owns that cache. Non-owning readers pass
2281        // publish=false so they don't clobber the server's live env-var cache.
2282        if publish {
2283            config.publish_env_vars();
2284        }
2285
2286        config
2287    }
2288
2289    /// Apply `BAMBOO_*` environment overrides (highest priority) onto a loaded
2290    /// config. Factored out so one-shot writers can skip it (see
2291    /// [`Config::from_data_dir_without_env`]).
2292    fn apply_env_overrides(&mut self) {
2293        if let Ok(port) = std::env::var("BAMBOO_PORT") {
2294            if let Ok(port) = port.parse() {
2295                self.server.port = port;
2296            }
2297        }
2298
2299        if let Ok(bind) = std::env::var("BAMBOO_BIND") {
2300            self.server.bind = bind;
2301        }
2302
2303        // Note: BAMBOO_DATA_DIR already handled by the caller.
2304        if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
2305            self.provider = provider;
2306        }
2307
2308        if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
2309            self.headless_auth = parse_bool_env(&headless);
2310        }
2311
2312        if let Ok(project_prompt_injection) =
2313            std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
2314        {
2315            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2316            memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
2317        }
2318
2319        if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
2320            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2321            memory.relevant_recall = parse_bool_env(&relevant_recall);
2322        }
2323
2324        if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
2325            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2326            memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
2327        }
2328
2329        if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
2330            let memory = self.memory.get_or_insert_with(MemoryConfig::default);
2331            memory.project_first_dream = parse_bool_env(&project_first_dream);
2332        }
2333
2334        // Per-provider API keys from the environment (highest priority). Lets a
2335        // 12-factor / secret-manager / --env-file / k8s-Secret deploy supply the
2336        // key at runtime instead of baking a plaintext `api_key` into a mounted
2337        // config.json. The `api_key_from_env` flag keeps `refresh_provider_api_keys_encrypted`
2338        // from re-encrypting these keys into `api_key_encrypted` on a later save,
2339        // so an env key is never persisted to disk. (#253)
2340        if let Ok(key) = std::env::var("BAMBOO_OPENAI_API_KEY") {
2341            let key = key.trim();
2342            if !key.is_empty() {
2343                let openai = self
2344                    .providers
2345                    .openai
2346                    .get_or_insert_with(OpenAIConfig::default);
2347                openai.api_key = key.to_string();
2348                openai.api_key_from_env = true;
2349            }
2350        }
2351        if let Ok(key) = std::env::var("BAMBOO_ANTHROPIC_API_KEY") {
2352            let key = key.trim();
2353            if !key.is_empty() {
2354                let anthropic = self
2355                    .providers
2356                    .anthropic
2357                    .get_or_insert_with(AnthropicConfig::default);
2358                anthropic.api_key = key.to_string();
2359                anthropic.api_key_from_env = true;
2360            }
2361        }
2362        if let Ok(key) = std::env::var("BAMBOO_GEMINI_API_KEY") {
2363            let key = key.trim();
2364            if !key.is_empty() {
2365                let gemini = self
2366                    .providers
2367                    .gemini
2368                    .get_or_insert_with(GeminiConfig::default);
2369                gemini.api_key = key.to_string();
2370                gemini.api_key_from_env = true;
2371            }
2372        }
2373    }
2374
2375    /// Load config from disk AND publish its env vars to the process-global cache
2376    /// (so Bash tools inject them). For the context that OWNS that cache — the
2377    /// server bootstrap. Library / secondary readers that only need to read a
2378    /// value must use [`Config::from_data_dir_without_publish`] instead, or they
2379    /// will clobber the server's live cache with stale disk data (#38 / #40).
2380    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
2381        Self::from_data_dir_impl(data_dir, true, true)
2382    }
2383
2384    /// Load config from disk WITHOUT publishing env vars to the global cache.
2385    /// For non-owning readers (e.g. permission storage) that just need a config
2386    /// value and must not clobber the live env-var cache. #40.
2387    pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
2388        Self::from_data_dir_impl(data_dir, false, true)
2389    }
2390
2391    /// Load config from disk WITHOUT applying `BAMBOO_*` env-var overrides and
2392    /// WITHOUT publishing to the global cache. For one-shot CLI writers
2393    /// (`bamboo init` / `config set`) that immediately re-save: applying env
2394    /// overrides here would bake transient values (port/bind/provider/memory
2395    /// flags) permanently into config.json. Same corruption-recovery + default
2396    /// fallback as the normal load.
2397    pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
2398        Self::from_data_dir_impl(data_dir, false, false)
2399    }
2400
2401    /// Merge the standalone `connect.json` (#455) onto `self.connect`, the
2402    /// load-side counterpart of [`save_connect_config`]. Called once per load,
2403    /// BEFORE [`Config::hydrate_connect_platform_tokens_from_encrypted`] runs,
2404    /// so hydration decrypts the POST-merge ciphertext rather than a stale
2405    /// copy still embedded in `config.json`.
2406    ///
2407    /// - `connect.json` present & parseable: authoritative — OVERWRITES
2408    ///   whatever `self.connect` currently holds. If `self.connect` was ALSO
2409    ///   non-empty (a legacy inline `connect` key still in config.json, e.g.
2410    ///   #453-era state, or written by an older binary), that's a stale
2411    ///   duplicate: log a warning and proactively strip the superseded key
2412    ///   from config.json now (#457) rather than waiting for the next
2413    ///   natural save — cheap, and consistent with not spreading token
2414    ///   ciphertext across files.
2415    /// - `connect.json` present but corrupt/unparsable: fail SAFE for this
2416    ///   security-sensitive feature. Log an error, quarantine the bad file to
2417    ///   `connect.json.bak` (best-effort), and continue with an EMPTY
2418    ///   `ConnectConfig` — never falls back to a legacy config.json copy.
2419    /// - `connect.json` absent & `self.connect` non-empty (pure legacy
2420    ///   state): migrate proactively. Adopt the legacy value (already parsed
2421    ///   into `self`) and persist it: strip the `connect` key from
2422    ///   config.json and write connect.json (#457 — NOT a full
2423    ///   [`Config::save_to_dir`], which would re-encrypt every OTHER secret
2424    ///   in config.json and rotate its backups as a load-time side effect,
2425    ///   even for a read-only command like `bamboo config get`), logged at
2426    ///   info.
2427    /// - `connect.json` absent & `self.connect` empty: nothing to do.
2428    fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
2429        let connect_path = data_dir.join("connect.json");
2430        match std::fs::read_to_string(&connect_path) {
2431            Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
2432                Ok(connect) => {
2433                    let legacy_key_present = !connect_config_is_empty(&self.connect);
2434                    if legacy_key_present {
2435                        tracing::warn!(
2436                            "config.json still has a legacy `connect` key alongside \
2437                             connect.json; connect.json takes precedence — dropping the \
2438                             stale key from config.json now"
2439                        );
2440                    }
2441                    self.connect = connect;
2442                    if legacy_key_present {
2443                        strip_legacy_connect_key_from_config_json(data_dir);
2444                    }
2445                }
2446                Err(e) => {
2447                    tracing::error!(
2448                        "Failed to parse {:?} ({}); continuing with an empty (inert) \
2449                         connect config instead of falling back to a legacy config.json copy",
2450                        connect_path,
2451                        e
2452                    );
2453                    quarantine_corrupt_connect(&connect_path);
2454                    self.connect = ConnectConfig::default();
2455                }
2456            },
2457            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2458                if !connect_config_is_empty(&self.connect) {
2459                    tracing::info!(
2460                        "Migrating legacy `connect` config from config.json to a \
2461                         standalone connect.json"
2462                    );
2463                    // Narrow migration write (#457): strip only the `connect` key
2464                    // from config.json and write connect.json directly, instead of
2465                    // routing through a full `save_to_dir` (see doc comment above).
2466                    strip_legacy_connect_key_from_config_json(data_dir);
2467                    if let Err(e) = save_connect_config(&self.connect, data_dir) {
2468                        tracing::error!("Failed to write connect.json during migration: {}", e);
2469                    }
2470                }
2471            }
2472            Err(e) => {
2473                tracing::error!(
2474                    "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
2475                    connect_path,
2476                    e
2477                );
2478                self.connect = ConnectConfig::default();
2479            }
2480        }
2481    }
2482
2483    /// Deserialize config JSON and run the in-memory hydration + normalization
2484    /// chain. Shared by the primary load and the backup-recovery path (#37).
2485    fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
2486        serde_json::from_str::<Config>(content).map(|mut config| {
2487            config.hydrate_proxy_auth_from_encrypted();
2488            config.hydrate_provider_api_keys_from_encrypted();
2489            config.hydrate_provider_instance_api_keys_from_encrypted();
2490            config.hydrate_mcp_secrets_from_encrypted();
2491            config.hydrate_env_vars_from_encrypted();
2492            config.hydrate_cluster_fabric_from_encrypted();
2493            config.hydrate_broker_token_from_encrypted();
2494            config.hydrate_notifications_from_encrypted();
2495            config.hydrate_connect_platform_tokens_from_encrypted();
2496            config.normalize_tool_settings();
2497            config.normalize_skill_settings();
2498            config
2499        })
2500    }
2501
2502    /// Try to recover from the rotated `config.json.bak[.N]` generations (each a
2503    /// last-known-good written before a save) when the primary `config.json` is
2504    /// corrupt. Walks newest -> oldest and returns the first that parses (paired
2505    /// with its generation index, 0 == `.bak`, for [`ConfigRecoverySource::Backup`]);
2506    /// `None` if every generation is missing or also unparseable. #37 / #135.
2507    fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
2508        let config_path = data_dir.join("config.json");
2509        for gen in 0..BAK_GENERATIONS {
2510            let backup = backup_path_for(&config_path, gen);
2511            let Ok(content) = std::fs::read_to_string(&backup) else {
2512                continue;
2513            };
2514            match Self::parse_and_hydrate(&content) {
2515                Ok(config) => {
2516                    tracing::info!("Recovered configuration from {:?}", backup);
2517                    return Some((config, gen));
2518                }
2519                Err(e) => {
2520                    tracing::warn!(
2521                        "Backup {:?} is unparseable ({}); trying an older generation",
2522                        backup,
2523                        e
2524                    );
2525                }
2526            }
2527        }
2528        None
2529    }
2530
2531    /// Largest corrupt-object key count we'll attempt to salvage. The overlay loop
2532    /// is O(keys) full-Config deserializes over a growing object (the `extra`
2533    /// catch-all absorbs unknown keys), i.e. O(n²) on a pathological file; cap it
2534    /// so a junk-key-flooded config.json can't stall a load. A real config has a
2535    /// few dozen top-level keys, so this only ever trips on garbage.
2536    const SALVAGE_MAX_KEYS: usize = 512;
2537
2538    /// Best-effort PARTIAL salvage of a corrupt `config.json` (#135): parse it as a
2539    /// generic JSON object and overlay each top-level field onto the richest
2540    /// known-good baseline — the last-known-good `config.json.bak` if present, else
2541    /// a fresh default — keeping only the fields that still yield a valid [`Config`].
2542    /// A single bad field (wrong type, malformed section, …) then keeps the
2543    /// baseline's value instead of discarding ALL the user's other settings.
2544    ///
2545    /// Overlaying onto `.bak` (rather than defaults) means the result is the
2546    /// best-of-both: the backup's complete recent-good state PLUS the corrupt
2547    /// file's still-valid newer edits on top — so salvage is never worse than the
2548    /// plain `.bak` fallback, removing the "sparse salvage defeats a rich backup"
2549    /// hazard. Tried BEFORE the bare `.bak` fallback.
2550    ///
2551    /// Returns the hydrated salvaged config, or `None` when the corrupt file isn't
2552    /// even a JSON object (nothing field-wise to salvage) so the caller falls
2553    /// through to `.bak` / defaults.
2554    ///
2555    /// NOTE: the per-field overlay guarantees a VALID `Config`, not a *maximal* or
2556    /// attribution-perfect one. Deterministic alphabetical key order (serde_json is
2557    /// BTreeMap-backed, no `preserve_order`) means a rename/alias pair like
2558    /// `mcp`/`mcpServers` can drop the second-seen even if it'd be valid alone — the
2559    /// outcome is still a valid config, just not necessarily the richest possible.
2560    ///
2561    /// Returns the hydrated salvaged config paired with the top-level keys that
2562    /// were actually recovered from the corrupt document (used to populate
2563    /// [`ConfigRecoverySource::Salvaged`]).
2564    fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
2565        // Must at least be a JSON object; otherwise there's nothing field-wise to
2566        // salvage (a truncated/garbage file just falls through to .bak/defaults).
2567        let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
2568        let corrupt_obj = corrupt.as_object()?;
2569        if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
2570            tracing::warn!(
2571                "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
2572                corrupt_obj.len(),
2573                Self::SALVAGE_MAX_KEYS
2574            );
2575            return None;
2576        }
2577
2578        // Overlay onto the richest known-good baseline: the last-known-good backup
2579        // if it parses, else a fresh default. This makes salvage >= the plain .bak
2580        // fallback in every case.
2581        let mut base = Self::load_backup(data_dir)
2582            .and_then(|(backup, _generation)| serde_json::to_value(backup).ok())
2583            .or_else(|| serde_json::to_value(Self::create_default()).ok())?;
2584        let base_obj = base.as_object_mut()?;
2585
2586        let mut salvaged: Vec<String> = Vec::new();
2587        for (key, value) in corrupt_obj {
2588            let previous = base_obj.insert(key.clone(), value.clone());
2589            // Keep the field iff the WHOLE config still deserializes with it
2590            // overlaid — base is valid before each step, so a failure isolates THIS
2591            // field as the corrupt one (and inter-field constraints are respected).
2592            if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
2593                salvaged.push(key.clone());
2594            } else {
2595                match previous {
2596                    Some(prev) => {
2597                        base_obj.insert(key.clone(), prev);
2598                    }
2599                    None => {
2600                        base_obj.remove(key);
2601                    }
2602                }
2603            }
2604        }
2605
2606        tracing::warn!(
2607            "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
2608             last-known-good/default value",
2609            salvaged.len(),
2610            salvaged.join(", ")
2611        );
2612
2613        // Re-serialize the rebuilt (all-valid) object and run it back through the
2614        // normal parse+hydrate path so secret-decryption / normalization match a
2615        // clean load exactly.
2616        let rebuilt = serde_json::to_string(&base).ok()?;
2617        Self::parse_and_hydrate(&rebuilt)
2618            .ok()
2619            .map(|config| (config, salvaged))
2620    }
2621
2622    /// Get the effective default model for the currently active provider.
2623    ///
2624    /// When `features.provider_model_ref` is enabled, reads from `defaults.chat`
2625    /// before falling back to legacy provider-specific config.
2626    ///
2627    /// Note: for most providers this is a required config value (returns None when absent).
2628    /// Copilot has a built-in fallback when no model is configured.
2629    pub fn get_model(&self) -> Option<String> {
2630        if self.features.provider_model_ref {
2631            if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
2632                return Some(model_ref.model.clone());
2633            }
2634        }
2635        match self.provider.as_str() {
2636            "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
2637            "anthropic" => self
2638                .providers
2639                .anthropic
2640                .as_ref()
2641                .and_then(|c| c.model.clone()),
2642            "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
2643            "copilot" => Some(
2644                self.providers
2645                    .copilot
2646                    .as_ref()
2647                    .and_then(|c| c.model.clone())
2648                    .unwrap_or_else(|| "gpt-4o".to_string()),
2649            ),
2650            _ => None,
2651        }
2652    }
2653
2654    /// Get the fast/cheap model for the currently active provider.
2655    ///
2656    /// When `features.provider_model_ref` is enabled, reads from `defaults.fast`
2657    /// before falling back to legacy provider-specific config.
2658    ///
2659    /// Used for lightweight tasks like title generation and summarization.
2660    /// Falls back to `get_model()` when no fast_model is configured.
2661    pub fn get_fast_model(&self) -> Option<String> {
2662        if self.features.provider_model_ref {
2663            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
2664                return Some(model_ref.model.clone());
2665            }
2666        }
2667        let fast = match self.provider.as_str() {
2668            "openai" => self
2669                .providers
2670                .openai
2671                .as_ref()
2672                .and_then(|c| c.fast_model.clone()),
2673            "anthropic" => self
2674                .providers
2675                .anthropic
2676                .as_ref()
2677                .and_then(|c| c.fast_model.clone()),
2678            "gemini" => self
2679                .providers
2680                .gemini
2681                .as_ref()
2682                .and_then(|c| c.fast_model.clone()),
2683            "copilot" => self
2684                .providers
2685                .copilot
2686                .as_ref()
2687                .and_then(|c| c.fast_model.clone()),
2688            _ => None,
2689        };
2690        fast.or_else(|| self.get_model())
2691    }
2692
2693    /// Get the configured task summarization model.
2694    ///
2695    /// When `features.provider_model_ref` is enabled, reads from
2696    /// `defaults.task_summary` before falling back through
2697    /// `defaults.memory_background` → `defaults.fast` → `defaults.chat`.
2698    ///
2699    /// This is used for conversation/task summarization and context compression.
2700    pub fn get_task_summary_model(&self) -> Option<String> {
2701        if self.features.provider_model_ref {
2702            if let Some(model_ref) = self
2703                .defaults
2704                .as_ref()
2705                .and_then(|d| d.task_summary.as_ref())
2706                .or_else(|| {
2707                    self.defaults
2708                        .as_ref()
2709                        .and_then(|d| d.memory_background.as_ref())
2710                })
2711                .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
2712                .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
2713            {
2714                return Some(model_ref.model.clone());
2715            }
2716        }
2717
2718        self.get_memory_background_model()
2719            .or_else(|| self.get_model())
2720    }
2721
2722    /// Get the configured memory/background summarization model.
2723    ///
2724    /// When `features.provider_model_ref` is enabled, reads from
2725    /// `defaults.memory_background` before falling back to legacy config.
2726    ///
2727    /// Falls back to the provider fast model when no background model is
2728    /// configured or resolves to an empty string.
2729    ///
2730    /// IMPORTANT: this intentionally does **not** fall back to the main
2731    /// interaction model. Memory compaction / reflection should be skipped or
2732    /// fail loudly when no background/fast model is configured.
2733    pub fn get_memory_background_model(&self) -> Option<String> {
2734        if self.features.provider_model_ref {
2735            if let Some(model_ref) = self
2736                .defaults
2737                .as_ref()
2738                .and_then(|d| d.memory_background.as_ref())
2739            {
2740                return Some(model_ref.model.clone());
2741            }
2742            if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
2743                return Some(model_ref.model.clone());
2744            }
2745        }
2746        let configured = self
2747            .memory
2748            .as_ref()
2749            .and_then(|memory| memory.background_model.as_ref())
2750            .map(|value| value.trim())
2751            .filter(|value| !value.is_empty())
2752            .map(ToString::to_string);
2753        configured.or_else(|| match self.provider.as_str() {
2754            "openai" => self
2755                .providers
2756                .openai
2757                .as_ref()
2758                .and_then(|c| c.fast_model.clone()),
2759            "anthropic" => self
2760                .providers
2761                .anthropic
2762                .as_ref()
2763                .and_then(|c| c.fast_model.clone()),
2764            "gemini" => self
2765                .providers
2766                .gemini
2767                .as_ref()
2768                .and_then(|c| c.fast_model.clone()),
2769            "copilot" => self
2770                .providers
2771                .copilot
2772                .as_ref()
2773                .and_then(|c| c.fast_model.clone()),
2774            _ => None,
2775        })
2776    }
2777
2778    /// Resolve the configured default work area path when present.
2779    ///
2780    /// This validates that the configured directory exists, but intentionally
2781    /// returns the stable expanded path rather than the platform-specific
2782    /// canonicalized path. On macOS, `canonicalize()` may rewrite `/var/...`
2783    /// to `/private/var/...`, which is correct at the filesystem layer but
2784    /// undesirable as a user-facing/config-derived workspace path.
2785    pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
2786        let raw = self
2787            .default_work_area
2788            .as_ref()
2789            .and_then(|config| config.path.as_ref())
2790            .map(|value| value.trim())
2791            .filter(|value| !value.is_empty())?;
2792
2793        let candidate = expand_user_path(raw);
2794        if candidate.is_absolute() {
2795            let canonical = std::fs::canonicalize(&candidate).ok();
2796            return canonical
2797                .as_ref()
2798                .filter(|path| path.is_dir())
2799                .map(|_| candidate.clone())
2800                .or_else(|| candidate.is_dir().then_some(candidate));
2801        }
2802
2803        let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
2804        let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
2805        canonical
2806            .as_ref()
2807            .filter(|path| path.is_dir())
2808            .map(|_| from_bamboo_dir.clone())
2809            .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
2810            .or_else(|| candidate.is_dir().then_some(candidate))
2811    }
2812
2813    /// Get the vision-capable model for the currently active provider.
2814    ///
2815    /// Used for image understanding tasks.
2816    /// Falls back to `get_model()` when no vision_model is configured.
2817    pub fn get_vision_model(&self) -> Option<String> {
2818        let vision = match self.provider.as_str() {
2819            "openai" => self
2820                .providers
2821                .openai
2822                .as_ref()
2823                .and_then(|c| c.vision_model.clone()),
2824            "anthropic" => self
2825                .providers
2826                .anthropic
2827                .as_ref()
2828                .and_then(|c| c.vision_model.clone()),
2829            "gemini" => self
2830                .providers
2831                .gemini
2832                .as_ref()
2833                .and_then(|c| c.vision_model.clone()),
2834            "copilot" => self
2835                .providers
2836                .copilot
2837                .as_ref()
2838                .and_then(|c| c.vision_model.clone()),
2839            _ => None,
2840        };
2841        vision.or_else(|| self.get_model())
2842    }
2843
2844    /// Get the default reasoning effort for the currently active provider.
2845    pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
2846        self.reasoning_effort_for_key(&self.provider)
2847    }
2848
2849    /// Resolve the configured default reasoning effort for a provider routing key.
2850    ///
2851    /// The key may be a multi-instance provider id (for example `"copilot-work"`)
2852    /// or a legacy provider type (for example `"openai"`). In multi-instance mode
2853    /// the per-instance `reasoning_effort` lives under `provider_instances[<id>]`,
2854    /// so we resolve instance ids there first; otherwise we fall back to the
2855    /// legacy per-provider config. Both the execute path
2856    /// ([`crate`]'s `get_reasoning_effort_for_provider`) and the session-create
2857    /// path ([`Self::get_reasoning_effort`]) delegate here so the two cannot drift.
2858    pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
2859        let trimmed = key.trim();
2860        if trimmed.is_empty() {
2861            return None;
2862        }
2863
2864        // Multi-instance mode: the routing key is an instance id.
2865        if let Some(instance) = self.provider_instances.get(trimmed) {
2866            return instance.reasoning_effort;
2867        }
2868
2869        // Legacy mode: the routing key is a provider type.
2870        match trimmed {
2871            "openai" => self
2872                .providers
2873                .openai
2874                .as_ref()
2875                .and_then(|c| c.reasoning_effort),
2876            "anthropic" => self
2877                .providers
2878                .anthropic
2879                .as_ref()
2880                .and_then(|c| c.reasoning_effort),
2881            "gemini" => self
2882                .providers
2883                .gemini
2884                .as_ref()
2885                .and_then(|c| c.reasoning_effort),
2886            "copilot" => self
2887                .providers
2888                .copilot
2889                .as_ref()
2890                .and_then(|c| c.reasoning_effort),
2891            "bodhi" => self
2892                .providers
2893                .bodhi
2894                .as_ref()
2895                .and_then(|c| c.reasoning_effort),
2896            _ => None,
2897        }
2898    }
2899
2900    /// Get normalized disabled tool names.
2901    pub fn disabled_tool_names(&self) -> BTreeSet<String> {
2902        self.tools
2903            .disabled
2904            .iter()
2905            .map(|name| name.trim())
2906            .filter(|name| !name.is_empty())
2907            .map(|name| normalize_tool_ref(name).unwrap_or_else(|| name.to_string()))
2908            .collect()
2909    }
2910
2911    /// Normalize tool settings (trim / dedupe / sort).
2912    pub fn normalize_tool_settings(&mut self) {
2913        self.tools.disabled = self.disabled_tool_names().into_iter().collect();
2914    }
2915
2916    /// Get normalized disabled skill IDs.
2917    pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
2918        self.skills
2919            .disabled
2920            .iter()
2921            .map(|id| id.trim())
2922            .filter(|id| !id.is_empty())
2923            .map(|id| id.to_string())
2924            .collect()
2925    }
2926
2927    /// Normalize skill settings (trim / dedupe / sort).
2928    pub fn normalize_skill_settings(&mut self) {
2929        self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
2930    }
2931
2932    /// Normalize `plugin_trust.trusted_hosts` entries (trim / lowercase / drop
2933    /// empties) so a hand-edited `config.json` doesn't silently accumulate
2934    /// mixed-case or whitespace-padded entries. [`is_host_trusted`] itself
2935    /// already matches case-insensitively regardless of how an entry is
2936    /// stored, so this is defense in depth / a canonical on-disk form, not
2937    /// the source of the security fix — that's the host/path-component
2938    /// matching in [`is_host_trusted`] itself.
2939    pub fn normalize_plugin_trust_settings(&mut self) {
2940        self.plugin_trust.trusted_hosts = self
2941            .plugin_trust
2942            .trusted_hosts
2943            .iter()
2944            .map(|entry| entry.trim().to_ascii_lowercase())
2945            .filter(|entry| !entry.is_empty())
2946            .collect();
2947    }
2948
2949    /// Return the effective default provider key.
2950    ///
2951    /// Prefers `default_provider_instance` when set; falls back to the
2952    /// legacy `provider` string.
2953    pub fn effective_default_provider(&self) -> &str {
2954        self.default_provider_instance
2955            .as_deref()
2956            .unwrap_or(&self.provider)
2957    }
2958
2959    /// Whether provider instances are configured (new multi-instance path).
2960    pub fn has_provider_instances(&self) -> bool {
2961        !self.provider_instances.is_empty()
2962    }
2963
2964    /// Build a flat map of all env vars with non-empty values (for process injection).
2965    pub fn env_vars_as_map(&self) -> HashMap<String, String> {
2966        self.env_vars
2967            .iter()
2968            .filter(|e| !e.value.trim().is_empty())
2969            .map(|e| (e.name.clone(), e.value.clone()))
2970            .collect()
2971    }
2972
2973    fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
2974        self.env_vars
2975            .iter()
2976            .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
2977            .map(|entry| PromptSafeEnvVarEntry {
2978                name: entry.name.clone(),
2979                secret: entry.secret,
2980                description: entry
2981                    .description
2982                    .as_ref()
2983                    .map(|value| value.trim().to_string())
2984                    .filter(|value| !value.is_empty()),
2985            })
2986            .collect()
2987    }
2988
2989    /// Update the global env vars cache (called on config load / reload).
2990    pub fn publish_env_vars(&self) {
2991        let map = self.env_vars_as_map();
2992        let mut env_guard = env_vars_cache().write().recover_poison();
2993        *env_guard = map;
2994
2995        let prompt_safe = self.prompt_safe_env_vars();
2996        let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
2997        *prompt_guard = prompt_safe;
2998    }
2999
3000    /// Read the current env vars snapshot (called by Bash tool at process spawn time).
3001    pub fn current_env_vars() -> HashMap<String, String> {
3002        env_vars_cache().read().recover_poison().clone()
3003    }
3004
3005    /// Read the current prompt-safe env var snapshot (names + metadata only; no secret values).
3006    pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
3007        prompt_safe_env_vars_cache().read().recover_poison().clone()
3008    }
3009
3010    /// Create a default configuration without loading from file
3011    fn create_default() -> Self {
3012        Config {
3013            http_proxy: String::new(),
3014            https_proxy: String::new(),
3015            proxy_auth: None,
3016            proxy_auth_encrypted: None,
3017            headless_auth: false,
3018            subagents: SubagentsConfig::default(),
3019            run_budget: RunBudgetConfig::default(),
3020            cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
3021            provider: default_provider(),
3022            providers: ProviderConfigs::default(),
3023            provider_instances: HashMap::new(),
3024            default_provider_instance: None,
3025            server: ServerConfig::default(),
3026            keyword_masking: KeywordMaskingConfig::default(),
3027            anthropic_model_mapping: AnthropicModelMapping::default(),
3028            gemini_model_mapping: GeminiModelMapping::default(),
3029            hooks: HooksConfig::default(),
3030            tools: ToolsConfig::default(),
3031            skills: SkillsConfig::default(),
3032            env_vars: Vec::new(),
3033            default_work_area: None,
3034            access_control: None,
3035            features: FeatureFlags::default(),
3036            defaults: None,
3037            memory: None,
3038            mcp: bamboo_domain::mcp_config::McpConfig::default(),
3039            notifications: NotificationsConfig::default(),
3040            connect: ConnectConfig::default(),
3041            plugin_trust: PluginTrustConfig::default(),
3042            extra: BTreeMap::new(),
3043            recovery_status: None,
3044        }
3045    }
3046
3047    /// Get the full server address (bind:port)
3048    pub fn server_addr(&self) -> String {
3049        format!("{}:{}", self.server.bind, self.server.port)
3050    }
3051
3052    /// Save configuration to disk
3053    pub fn save(&self) -> Result<()> {
3054        self.save_to_dir(default_data_dir())
3055    }
3056
3057    /// The pending config-corruption recovery, if `config.json` failed to
3058    /// parse on load and the recovery hasn't been confirmed yet. `None` on
3059    /// every clean load. #153.
3060    pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
3061        self.recovery_status.as_ref()
3062    }
3063
3064    /// Confirm a pending recovery, allowing the next [`Config::save`] /
3065    /// [`Config::save_to_dir`] to overwrite the quarantined-corrupt
3066    /// `config.json` with this recovered state. No-op if there's no pending
3067    /// recovery. Prefer [`Config::confirm_recovery_and_save_to_dir`], which
3068    /// also persists and clears the flag in one step. #153.
3069    pub fn confirm_recovery(&mut self) {
3070        if let Some(status) = self.recovery_status.as_mut() {
3071            status.confirmed = true;
3072        }
3073    }
3074
3075    /// Confirm a pending recovery AND persist it in one step: marks it
3076    /// confirmed (satisfying the [`Config::save_to_dir`] guard), writes the
3077    /// recovered state to `config.json`, then clears `recovery_status`
3078    /// entirely — once this succeeds the config is no longer "pending
3079    /// confirmation", it's just the normal on-disk config. Errors (and
3080    /// leaves `recovery_status` untouched) if there's nothing pending, or if
3081    /// the save itself fails. #153.
3082    pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
3083        if self.recovery_status.is_none() {
3084            anyhow::bail!("No pending config-corruption recovery to confirm");
3085        }
3086        self.confirm_recovery();
3087        self.save_to_dir(data_dir)?;
3088        self.recovery_status = None;
3089        Ok(())
3090    }
3091
3092    /// Assign a stable [`ConnectPlatformConfig::id`] to every `connect.platforms`
3093    /// entry that doesn't already have one (#496).
3094    ///
3095    /// Migration-on-write: [`Config::save_to_dir`] always calls this on its
3096    /// internal save-copy before persisting, so every path that writes
3097    /// `connect.json` gets ids backfilled. Callers that mutate the *live*
3098    /// in-memory config as part of a save (e.g. the server's settings-PATCH
3099    /// handler) should also call this directly on that in-memory value
3100    /// before responding, so a client that echoes the response straight
3101    /// back round-trips the id immediately rather than only after the next
3102    /// reload/restart. Never called from load — a config that's never saved
3103    /// again (e.g. one sitting in an unconfirmed-recovery state, see #493)
3104    /// is never rewritten just to backfill ids. An entry that already has
3105    /// an id keeps it unchanged; ids are never reassigned or deduplicated
3106    /// once set.
3107    pub fn assign_connect_platform_ids(&mut self) {
3108        for platform in &mut self.connect.platforms {
3109            if platform.id.is_none() {
3110                platform.id = Some(uuid::Uuid::new_v4().to_string());
3111            }
3112        }
3113    }
3114
3115    /// Save configuration to disk under the provided data directory.
3116    ///
3117    /// Configuration is always stored as `{data_dir}/config.json`.
3118    ///
3119    /// Refuses to write when this config carries an unconfirmed
3120    /// [`ConfigRecoveryStatus`] (#153) — i.e. it was recovered from a corrupt
3121    /// `config.json` and the recovery hasn't been confirmed — so a corrupt
3122    /// original a user might want to hand-fix is never silently clobbered by
3123    /// an auto-persisted recovery. Call [`Config::confirm_recovery`] (or
3124    /// [`Config::confirm_recovery_and_save_to_dir`]) first.
3125    pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
3126        if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
3127            anyhow::bail!(
3128                "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
3129                 has not been confirmed; the corrupt original is preserved at {:?}. Call \
3130                 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
3131                status.source,
3132                status.quarantine_path,
3133            );
3134        }
3135
3136        let path = data_dir.join("config.json");
3137
3138        if let Some(parent) = path.parent() {
3139            std::fs::create_dir_all(parent)
3140                .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
3141        }
3142
3143        let mut to_save = self.clone();
3144        // Never persist `data_dir` into config.json (data dir is runtime-derived).
3145        to_save.extra.remove("data_dir");
3146        // Root-level `model` is deprecated; do not persist it.
3147        to_save.extra.remove("model");
3148        // `subagents.broker` is `#[serde(skip)]` (runtime-only, lives in its own
3149        // broker.json / embedded in-process) — nothing to encrypt or persist here.
3150        to_save.refresh_encrypted_secrets()?;
3151        to_save.sanitize_env_vars_for_disk();
3152        to_save.sanitize_cluster_fabric_for_disk();
3153        to_save.assign_connect_platform_ids();
3154        to_save.normalize_tool_settings();
3155        to_save.normalize_skill_settings();
3156
3157        // Split `connect` (#455) out of the config.json document: bamboo-connect
3158        // platform-bridge credentials (bot tokens, allowlists) get their own
3159        // sibling file, connect.json (written below), instead of living in
3160        // config.json — different sensitivity/lifecycle. The `connect` FIELD on
3161        // `Config` keeps its normal serde shape unchanged (still required by the
3162        // settings API / `preserve_masked_connect_secrets`, which operate on the
3163        // in-memory struct) — only the serialized DOCUMENT that becomes
3164        // config.json's bytes has the key stripped, and that's done on the
3165        // `serde_json::Value` here, not via `#[serde(skip)]` on the field.
3166        let mut config_value =
3167            serde_json::to_value(&to_save).context("Failed to serialize config to JSON")?;
3168        if let Some(obj) = config_value.as_object_mut() {
3169            obj.remove("connect");
3170        }
3171        let content = serde_json::to_string_pretty(&config_value)
3172            .context("Failed to serialize config to JSON")?;
3173
3174        // Back up the current on-disk config (last-known-good) before overwriting,
3175        // so corruption (a bad/partial write, external edit, disk issue) stays
3176        // recoverable via config.json.bak on the next load. Best-effort. Only
3177        // refresh the backup from a PARSEABLE config.json — otherwise a save right
3178        // after an in-memory recovery (where the on-disk config.json is still the
3179        // corrupt original) would clobber the good .bak with garbage. #37.
3180        if path.exists()
3181            && std::fs::read_to_string(&path)
3182                .ok()
3183                .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
3184        {
3185            // Rotate the older generations down (.bak -> .bak.1 -> .bak.2 …) so a
3186            // few last-known-good snapshots survive, then snapshot the current
3187            // (parseable) config.json as the freshest .bak. #135.
3188            rotate_backups(&path, BAK_GENERATIONS);
3189            let backup = backup_path_for(&path, 0);
3190            if let Err(e) = std::fs::copy(&path, &backup) {
3191                tracing::warn!("Failed to back up config.json before save: {}", e);
3192            }
3193        }
3194
3195        write_atomic(&path, content.as_bytes())
3196            .with_context(|| format!("Failed to write config file: {:?}", path))?;
3197
3198        save_connect_config(&to_save.connect, &data_dir)?;
3199
3200        Ok(())
3201    }
3202}
3203
3204/// Persist `connect` (#455) to its own sibling file, `connect.json`, next to
3205/// config.json — the save-side counterpart of [`Config::merge_connect_config`].
3206///
3207/// Only writes when the config is non-empty OR the file already exists, so a
3208/// fresh/default install with no platforms configured never gets a
3209/// `connect.json` littering its data dir. Before an existing file is
3210/// overwritten, it's copied aside to a single `connect.json.bak` generation
3211/// (best-effort) — connect.json doesn't need config.json's multi-generation
3212/// rotation, one last-known-good snapshot is enough.
3213fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
3214    let path = data_dir.join("connect.json");
3215    if connect_config_is_empty(connect) && !path.exists() {
3216        return Ok(());
3217    }
3218
3219    if path.exists() {
3220        let backup = path.with_extension("json.bak");
3221        if let Err(e) = std::fs::copy(&path, &backup) {
3222            tracing::warn!("Failed to back up connect.json before save: {}", e);
3223        }
3224    }
3225
3226    let content = serde_json::to_string_pretty(connect)
3227        .context("Failed to serialize connect config to JSON")?;
3228    write_atomic(&path, content.as_bytes())
3229        .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
3230    Ok(())
3231}
3232
3233/// Remove the legacy inline `connect` key from `config.json` on disk, if
3234/// present — the narrow, load-side counterpart of the full-document rewrite
3235/// [`Config::save_to_dir`] would otherwise perform just to drop one stale
3236/// key. Used by [`Config::merge_connect_config`] both when adopting a
3237/// pure-legacy `connect` key (migration) and when a stale legacy key lingers
3238/// alongside an authoritative connect.json. #457.
3239///
3240/// Operates on the raw `serde_json::Value` read straight from disk — NOT on
3241/// the typed `Config` — so it touches nothing but the one key: no other
3242/// secret gets re-encrypted, and no `config.json.bak` generation gets
3243/// rotated, as a side effect of a load.
3244///
3245/// Best-effort: read/parse/write failures are logged, not propagated — this
3246/// runs as a side effect of `Config::new()` / load, which has no `Result` to
3247/// surface it through. A failure here just leaves the stale key in place
3248/// until the next natural save; connect.json (written separately) is already
3249/// authoritative in memory either way.
3250fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
3251    let config_path = data_dir.join("config.json");
3252    let content = match std::fs::read_to_string(&config_path) {
3253        Ok(content) => content,
3254        Err(e) => {
3255            tracing::error!(
3256                "Failed to read config.json to strip legacy `connect` key: {}",
3257                e
3258            );
3259            return;
3260        }
3261    };
3262    let mut value: serde_json::Value = match serde_json::from_str(&content) {
3263        Ok(value) => value,
3264        Err(e) => {
3265            tracing::error!(
3266                "Failed to parse config.json to strip legacy `connect` key: {}",
3267                e
3268            );
3269            return;
3270        }
3271    };
3272    let Some(obj) = value.as_object_mut() else {
3273        return;
3274    };
3275    if obj.remove("connect").is_none() {
3276        // Nothing to strip (e.g. raced with a concurrent save that already
3277        // dropped it) — avoid an unnecessary rewrite.
3278        return;
3279    }
3280    let rewritten = match serde_json::to_string_pretty(&value) {
3281        Ok(rewritten) => rewritten,
3282        Err(e) => {
3283            tracing::error!(
3284                "Failed to serialize config.json after stripping legacy `connect` key: {}",
3285                e
3286            );
3287            return;
3288        }
3289    };
3290    if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
3291        tracing::error!(
3292            "Failed to write config.json after stripping legacy `connect` key: {}",
3293            e
3294        );
3295    }
3296}
3297
3298/// Sweep the rotated `config.json.bak[.N]` generations for a legacy embedded
3299/// `connect` sub-tree that predates the #455 connect.json split, and strip it
3300/// in place. #468 (follow-up to #457).
3301///
3302/// `strip_legacy_connect_key_from_config_json` only ever rewrites the CURRENT
3303/// `config.json` — it never reaches into `.bak` generations, and the normal
3304/// backup-rotation path (see [`rotate_backups`]) only overwrites a `.bak[.N]`
3305/// slot as a side effect of a fresh SAVE. An instance that upgraded from a
3306/// pre-#455 build but rarely (or never) triggers a config save can therefore
3307/// carry the legacy, encrypted `connect` sub-tree — including bot tokens, an
3308/// immediately-usable remote-control credential — in an old backup generation
3309/// indefinitely, even after its live config.json has long since been
3310/// migrated.
3311///
3312/// Deliberately surgical, mirroring the `.bak` files' role as the user's
3313/// recovery net (#493's "backups are a low-sensitivity snapshot, don't fuss
3314/// with them" posture):
3315/// - a generation that doesn't exist, or that fails to parse as JSON, is
3316///   SKIPPED — logged, never deleted, never guessed at. Corrupt/foreign
3317///   content in a `.bak` slot is left exactly as found for hand inspection.
3318/// - a generation that parses but carries no `connect` key (the overwhelming
3319///   majority, especially on any instance that predates this fix by more
3320///   than `BAK_GENERATIONS` saves) is left COMPLETELY untouched — not even a
3321///   byte-identical rewrite — so its mtime and on-disk bytes survive.
3322/// - only a generation that actually parses AND carries the legacy key gets
3323///   rewritten, via the same key-removal-on-the-raw-`Value` + `write_atomic`
3324///   approach as `strip_legacy_connect_key_from_config_json`, so every other
3325///   byte of that snapshot (all other settings, formatting aside) survives.
3326///
3327/// Runs unconditionally on every load (not gated on the CURRENT config.json
3328/// still carrying the legacy key) specifically to catch already-migrated
3329/// installs whose backups predate this fix. Cheap: at most `BAK_GENERATIONS`
3330/// small file reads, and a genuine no-op (zero writes) once every generation
3331/// has been swept once. Best-effort like its sibling: failures are logged,
3332/// not propagated, since this runs as a side effect of `Config::new()` /
3333/// load, which has no `Result` to surface it through.
3334fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
3335    let config_path = data_dir.join("config.json");
3336    for gen in 0..BAK_GENERATIONS {
3337        let backup = backup_path_for(&config_path, gen);
3338        let content = match std::fs::read_to_string(&backup) {
3339            Ok(content) => content,
3340            Err(e) => {
3341                if e.kind() != std::io::ErrorKind::NotFound {
3342                    tracing::warn!(
3343                        "Failed to read {:?} while scanning for legacy connect data ({}); \
3344                         leaving it untouched",
3345                        backup,
3346                        e
3347                    );
3348                }
3349                continue;
3350            }
3351        };
3352        let mut value: serde_json::Value = match serde_json::from_str(&content) {
3353            Ok(value) => value,
3354            Err(e) => {
3355                tracing::warn!(
3356                    "Skipping unparsable backup {:?} while scanning for legacy connect data \
3357                     ({}); left untouched (never deleted)",
3358                    backup,
3359                    e
3360                );
3361                continue;
3362            }
3363        };
3364        let Some(obj) = value.as_object_mut() else {
3365            // Not a JSON object (e.g. `null`/an array) — nothing to strip, and
3366            // not a shape we should try to rewrite. Leave it alone.
3367            continue;
3368        };
3369        if obj.remove("connect").is_none() {
3370            // No legacy key in this generation — skip without writing so the
3371            // file's bytes/mtime are left completely untouched.
3372            continue;
3373        }
3374        let rewritten = match serde_json::to_string_pretty(&value) {
3375            Ok(rewritten) => rewritten,
3376            Err(e) => {
3377                tracing::error!(
3378                    "Failed to serialize {:?} after stripping legacy connect data: {}",
3379                    backup,
3380                    e
3381                );
3382                continue;
3383            }
3384        };
3385        match write_atomic(&backup, rewritten.as_bytes()) {
3386            Ok(()) => tracing::info!(
3387                "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
3388                backup
3389            ),
3390            Err(e) => tracing::error!(
3391                "Failed to write {:?} after stripping legacy connect data: {}",
3392                backup,
3393                e
3394            ),
3395        }
3396    }
3397}
3398
3399/// Quarantine an unparsable `connect.json` to a single `connect.json.bak`
3400/// generation (best-effort) so the bad content survives for inspection
3401/// instead of being silently discarded. Unlike config.json's timestamped,
3402/// N-generation quarantine, connect.json only needs one slot — it's a much
3403/// smaller, less complex document and this is a fail-SAFE (empty/inert
3404/// bridge), not a fail-recover, posture. #455.
3405///
3406/// MOVES the corrupt file rather than copying it (#457): a copy would leave
3407/// the same corrupt `connect.json` sitting in the data dir right next to its
3408/// own quarantine copy, which reads as confusing/ambiguous mid-incident
3409/// (which one is live?). `rename` is used first (atomic, no partial-copy
3410/// window); if that fails — e.g. `connect.json.bak` and the data dir are on
3411/// different filesystems — fall back to copy-then-remove so the corrupt
3412/// original still doesn't linger.
3413fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
3414    let backup = connect_path.with_extension("json.bak");
3415    match std::fs::rename(connect_path, &backup) {
3416        Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
3417        Err(e) => {
3418            tracing::warn!(
3419                "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
3420                backup,
3421                e
3422            );
3423            if let Err(e) = std::fs::copy(connect_path, &backup) {
3424                tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
3425                return;
3426            }
3427            if let Err(e) = std::fs::remove_file(connect_path) {
3428                tracing::error!(
3429                    "Quarantined corrupt connect.json to {:?} but failed to remove the \
3430                     original {:?}: {}",
3431                    backup,
3432                    connect_path,
3433                    e
3434                );
3435            }
3436        }
3437    }
3438}
3439
3440/// How many `config.json.corrupted.*` quarantine files to keep. Each corrupt load
3441/// drops one; without a cap they accumulate unbounded. Newest `N` are retained.
3442const QUARANTINE_KEEP: usize = 5;
3443
3444/// Copy a corrupt config file aside to `config.json.corrupted.<nanos>` so the
3445/// user's (unparseable) configuration is preserved for inspection/recovery
3446/// instead of being silently discarded and then overwritten by defaults. #37.
3447///
3448/// Returns the quarantine path on success, so the caller can attach it to a
3449/// [`ConfigRecoveryStatus`] (#153); `None` if even the copy failed (the
3450/// corrupt original is still left in place at `config_path` regardless, since
3451/// this only ever copies, never moves/deletes).
3452fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
3453    let nanos = std::time::SystemTime::now()
3454        .duration_since(std::time::UNIX_EPOCH)
3455        .map(|d| d.as_nanos())
3456        .unwrap_or(0);
3457    // Two corrupt loads in the same nanosecond would land on the same name and the
3458    // second `copy` would silently overwrite the first. Append a counter on
3459    // collision so each quarantine is preserved distinctly. #135.
3460    let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
3461    let mut dedup = 1u32;
3462    while quarantine.exists() {
3463        quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
3464        dedup += 1;
3465    }
3466    let result = match std::fs::copy(config_path, &quarantine) {
3467        Ok(_) => {
3468            tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
3469            Some(quarantine)
3470        }
3471        Err(e) => {
3472            tracing::error!("Failed to quarantine corrupt config.json: {}", e);
3473            None
3474        }
3475    };
3476    prune_quarantine_files(config_path, QUARANTINE_KEEP);
3477    result
3478}
3479
3480/// Keep only the newest `keep` `config.json.corrupted.*` files next to
3481/// `config_path`, deleting older ones so quarantines don't grow unbounded. #135.
3482fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
3483    let Some(dir) = config_path.parent() else {
3484        return;
3485    };
3486    let prefix = "config.json.corrupted.";
3487    let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
3488        Ok(entries) => entries
3489            .filter_map(|e| e.ok())
3490            .map(|e| e.path())
3491            .filter(|p| {
3492                p.file_name()
3493                    .and_then(|n| n.to_str())
3494                    .is_some_and(|n| n.starts_with(prefix))
3495            })
3496            .collect(),
3497        Err(_) => return,
3498    };
3499    if quarantines.len() <= keep {
3500        return;
3501    }
3502    // Oldest first (by mtime; missing mtime sorts oldest so it's pruned first).
3503    quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
3504    let remove = quarantines.len() - keep;
3505    for stale in quarantines.into_iter().take(remove) {
3506        if let Err(e) = std::fs::remove_file(&stale) {
3507            tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
3508        }
3509    }
3510}
3511
3512/// Number of `config.json.bak[.N]` generations to retain (`.bak` + `N-1` numbered).
3513/// More generations = more recovery points if a fresher backup is itself bad. #135.
3514const BAK_GENERATIONS: usize = 3;
3515
3516/// The on-disk path of backup generation `gen` (0 == `config.json.bak`).
3517fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
3518    if gen == 0 {
3519        config_path.with_extension("json.bak")
3520    } else {
3521        config_path.with_extension(format!("json.bak.{gen}"))
3522    }
3523}
3524
3525/// Shift the backup generations down before a fresh `.bak` is written:
3526/// `.bak.(N-2) -> .bak.(N-1)`, …, `.bak -> .bak.1`. The oldest is overwritten by
3527/// the shift; the caller then writes the new `.bak`. Walks the highest (oldest)
3528/// destination slot first so no rename clobbers a slot a later move still needs to
3529/// read. Best-effort. #135.
3530fn rotate_backups(config_path: &std::path::Path, generations: usize) {
3531    for gen in (1..generations).rev() {
3532        let from = backup_path_for(config_path, gen - 1);
3533        let to = backup_path_for(config_path, gen);
3534        if from.exists() {
3535            if let Err(e) = std::fs::rename(&from, &to) {
3536                tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
3537            }
3538        }
3539    }
3540}
3541
3542fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
3543    let Some(parent) = path.parent() else {
3544        return std::fs::write(path, content);
3545    };
3546
3547    std::fs::create_dir_all(parent)?;
3548
3549    // Write to a temp file in the same directory then rename to ensure atomic replace.
3550    // (Rename is atomic on Unix when source/dest are on the same filesystem.)
3551    //
3552    // The temp name must be unique PER CALL, not just per-process (issue
3553    // #486): it used to be derived from `process_id()` alone, which is
3554    // IDENTICAL across every thread of the same process. Two `write_atomic`
3555    // calls racing on the same directory (observed: two `#[test]` fns in
3556    // this file's suite, run concurrently by the default multi-threaded
3557    // test harness) therefore computed the exact same `tmp_path`. Whichever
3558    // caller's `File::create` ran second truncated the first caller's
3559    // in-flight temp file out from under it; whichever caller's `rename`
3560    // then lost the race failed with ENOENT (its temp file had already been
3561    // renamed away by the other caller) — reproducing
3562    // `save_rotates_backup_generations`'s exact CI failure: "Failed to
3563    // write config file ... No such file or directory (os error 2)". A
3564    // monotonic per-process counter alongside the PID makes every call's
3565    // temp file distinct, regardless of how many callers target the same
3566    // directory concurrently.
3567    static NEXT_TMP_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3568    let unique = NEXT_TMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3569    let file_name = path
3570        .file_name()
3571        .and_then(|s| s.to_str())
3572        .unwrap_or("config.json");
3573    let tmp_name = format!(".{}.tmp.{}.{}", file_name, std::process::id(), unique);
3574    let tmp_path = parent.join(tmp_name);
3575
3576    {
3577        let mut file = std::fs::File::create(&tmp_path)?;
3578        file.write_all(content)?;
3579        file.sync_all()?;
3580    }
3581
3582    std::fs::rename(&tmp_path, path)?;
3583    Ok(())
3584}
3585
3586#[cfg(test)]
3587mod tests {
3588    use super::*;
3589    use std::ffi::OsString;
3590    use std::path::PathBuf;
3591    use std::sync::Mutex;
3592    use std::time::{SystemTime, UNIX_EPOCH};
3593
3594    struct EnvVarGuard {
3595        key: &'static str,
3596        previous: Option<OsString>,
3597    }
3598
3599    impl EnvVarGuard {
3600        fn set(key: &'static str, value: &str) -> Self {
3601            let previous = std::env::var_os(key);
3602            std::env::set_var(key, value);
3603            Self { key, previous }
3604        }
3605
3606        fn unset(key: &'static str) -> Self {
3607            let previous = std::env::var_os(key);
3608            std::env::remove_var(key);
3609            Self { key, previous }
3610        }
3611    }
3612
3613    impl Drop for EnvVarGuard {
3614        fn drop(&mut self) {
3615            match &self.previous {
3616                Some(value) => std::env::set_var(self.key, value),
3617                None => std::env::remove_var(self.key),
3618            }
3619        }
3620    }
3621
3622    #[test]
3623    fn run_budget_config_merge_is_tighten_only_per_field() {
3624        let config_default = RunBudgetConfig {
3625            max_total_tokens: Some(100_000),
3626            max_tool_calls: Some(500),
3627            max_subagents: Some(10),
3628        };
3629
3630        // No override at all: config default passes through unchanged.
3631        assert_eq!(
3632            config_default.merged_with_override(None),
3633            config_default,
3634            "no override falls back to the config default entirely"
3635        );
3636
3637        // Override TIGHTENS exactly one field; the other two keep the config
3638        // default (per-field, not all-or-nothing).
3639        let tighten_one = RunBudgetConfig {
3640            max_total_tokens: Some(5_000),
3641            max_tool_calls: None,
3642            max_subagents: None,
3643        };
3644        let merged = config_default.merged_with_override(Some(&tighten_one));
3645        assert_eq!(merged.max_total_tokens, Some(5_000));
3646        assert_eq!(merged.max_tool_calls, Some(500));
3647        assert_eq!(merged.max_subagents, Some(10));
3648
3649        // A LOOSER override is clamped to the config default: a client can
3650        // never raise the operator's ceiling (PR #539 review, finding #3).
3651        let loosen_attempt = RunBudgetConfig {
3652            max_total_tokens: Some(999_999_999),
3653            max_tool_calls: Some(10_000),
3654            max_subagents: Some(1_000),
3655        };
3656        assert_eq!(
3657            config_default.merged_with_override(Some(&loosen_attempt)),
3658            config_default,
3659            "looser per-request values must be clamped to the config ceiling"
3660        );
3661
3662        // Nor can it REMOVE a configured ceiling by omitting the field: an
3663        // absent override field keeps the config default, it does not mean
3664        // unlimited.
3665        let empty_override = RunBudgetConfig::default();
3666        assert_eq!(
3667            config_default.merged_with_override(Some(&empty_override)),
3668            config_default,
3669            "an all-absent override body keeps every configured ceiling"
3670        );
3671
3672        // An unlimited config default CAN be tightened by the request (the
3673        // request is the only ceiling then), and stays unlimited on fields the
3674        // request does not set.
3675        let unlimited_default = RunBudgetConfig::default();
3676        let merged = unlimited_default.merged_with_override(Some(&tighten_one));
3677        assert_eq!(merged.max_total_tokens, Some(5_000));
3678        assert_eq!(merged.max_tool_calls, None);
3679        assert_eq!(merged.max_subagents, None);
3680    }
3681
3682    #[test]
3683    fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
3684        assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
3685        assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
3686        assert_eq!(RunBudgetConfig::default().max_subagents, None);
3687
3688        let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
3689        let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
3690        assert_eq!(cfg.max_total_tokens, Some(250_000));
3691        assert_eq!(
3692            cfg.max_tool_calls, None,
3693            "absent field defaults to unlimited"
3694        );
3695        assert_eq!(cfg.max_subagents, Some(3));
3696
3697        // Absent fields are omitted on serialize (skip_serializing_if), so an
3698        // all-default config round-trips to `{}` rather than three explicit
3699        // nulls.
3700        let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
3701        assert_eq!(empty, "{}");
3702    }
3703
3704    #[test]
3705    fn subagents_config_without_remote_placements_deserializes_empty() {
3706        // An OLD config (predating P1.5) has no `remote_placements` key — it must
3707        // still deserialize, with an empty placement list (default = local path).
3708        let json = r#"{ "max_concurrent": 4 }"#;
3709        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
3710        assert_eq!(cfg.max_concurrent, Some(4));
3711        assert!(cfg.remote_placements.is_empty());
3712        // And an empty placement list is omitted on re-serialize (skip_if empty).
3713        let back = serde_json::to_string(&cfg).unwrap();
3714        assert!(
3715            !back.contains("remote_placements"),
3716            "empty vec is skipped: {back}"
3717        );
3718    }
3719
3720    #[test]
3721    fn remote_actor_placement_round_trips() {
3722        let json = r#"{
3723            "remote_placements": [
3724                {
3725                    "role": "explorer",
3726                    "endpoint": "wss://gpu-host:8443",
3727                    "token_env": "WORKER_TOKEN",
3728                    "ca_cert_file": "/etc/bamboo/worker.pem"
3729                },
3730                { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
3731            ]
3732        }"#;
3733        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
3734        assert_eq!(cfg.remote_placements.len(), 2);
3735        let p0 = &cfg.remote_placements[0];
3736        assert_eq!(p0.role, "explorer");
3737        assert_eq!(p0.endpoint, "wss://gpu-host:8443");
3738        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
3739        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
3740        // Optional fields default to None and are skipped on serialize.
3741        let p1 = &cfg.remote_placements[1];
3742        assert_eq!(p1.role, "writer");
3743        assert!(p1.token_env.is_none());
3744        assert!(p1.ca_cert_file.is_none());
3745
3746        let back = serde_json::to_string(&cfg).unwrap();
3747        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
3748        assert_eq!(cfg, reparsed, "round-trip is stable");
3749        assert!(!back.contains("\"token_env\":null"));
3750        assert!(!back.contains("\"ca_cert_file\":null"));
3751    }
3752
3753    #[test]
3754    fn subagents_config_without_schedulable_placements_deserializes_empty() {
3755        // An OLD config (predating P2b) has no `schedulable_placements` key — it
3756        // must still deserialize, with an empty list (default = local path).
3757        let json = r#"{ "max_concurrent": 4 }"#;
3758        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
3759        assert!(cfg.schedulable_placements.is_empty());
3760        // An empty list is omitted on re-serialize (skip_if empty).
3761        let back = serde_json::to_string(&cfg).unwrap();
3762        assert!(
3763            !back.contains("schedulable_placements"),
3764            "empty vec is skipped: {back}"
3765        );
3766    }
3767
3768    #[test]
3769    fn schedulable_placement_round_trips() {
3770        let json = r#"{
3771            "schedulable_placements": [
3772                {
3773                    "role": "explorer",
3774                    "pool": "gpu-pool",
3775                    "registry_url": "https://control-plane:9562",
3776                    "token_env": "WORKER_TOKEN",
3777                    "ca_cert_file": "/etc/bamboo/worker.pem"
3778                },
3779                { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
3780            ]
3781        }"#;
3782        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
3783        assert_eq!(cfg.schedulable_placements.len(), 2);
3784        let p0 = &cfg.schedulable_placements[0];
3785        assert_eq!(p0.role, "explorer");
3786        assert_eq!(p0.pool, "gpu-pool");
3787        assert_eq!(p0.registry_url, "https://control-plane:9562");
3788        assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
3789        assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
3790        // Optional fields default to None and are skipped on serialize.
3791        let p1 = &cfg.schedulable_placements[1];
3792        assert_eq!(p1.role, "writer");
3793        assert_eq!(p1.pool, "cpu-pool");
3794        assert!(p1.token_env.is_none());
3795        assert!(p1.ca_cert_file.is_none());
3796
3797        let back = serde_json::to_string(&cfg).unwrap();
3798        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
3799        assert_eq!(cfg, reparsed, "round-trip is stable");
3800        assert!(!back.contains("\"token_env\":null"));
3801        assert!(!back.contains("\"ca_cert_file\":null"));
3802    }
3803
3804    #[test]
3805    fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
3806        // An OLD config (predating #54's wiring) has no `mcp_role_allowlist`
3807        // key — it must still deserialize, with an empty list (default =
3808        // every role unrestricted, identical to pre-#54 behavior).
3809        let json = r#"{ "max_concurrent": 4 }"#;
3810        let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
3811        assert!(cfg.mcp_role_allowlist.is_empty());
3812        // An empty list is omitted on re-serialize (skip_if empty).
3813        let back = serde_json::to_string(&cfg).unwrap();
3814        assert!(
3815            !back.contains("mcp_role_allowlist"),
3816            "empty vec is skipped: {back}"
3817        );
3818    }
3819
3820    #[test]
3821    fn mcp_role_allowlist_entry_round_trips() {
3822        let json = r#"{
3823            "mcp_role_allowlist": [
3824                { "role": "researcher", "tools": ["fetch_url"] },
3825                { "role": "sandboxed", "tools": [] }
3826            ]
3827        }"#;
3828        let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
3829        assert_eq!(cfg.mcp_role_allowlist.len(), 2);
3830        assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
3831        assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
3832        // An empty `tools` list is an explicit lockout, distinct from the role
3833        // being absent — it must round-trip as an empty (not omitted) list.
3834        assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
3835        assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
3836
3837        let back = serde_json::to_string(&cfg).unwrap();
3838        let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
3839        assert_eq!(cfg, reparsed, "round-trip is stable");
3840    }
3841
3842    #[test]
3843    fn server_config_without_tls_field_deserializes_back_compat() {
3844        // An old config.json `server` section with no `tls` key must still
3845        // deserialize, leaving `tls` as None (zero behavior change on upgrade).
3846        let server: ServerConfig = serde_json::from_value(serde_json::json!({
3847            "port": 9562,
3848            "bind": "127.0.0.1"
3849        }))
3850        .expect("legacy server config without tls should deserialize");
3851
3852        assert_eq!(server.tls, None);
3853        assert_eq!(server.port, 9562);
3854        assert_eq!(server.bind, "127.0.0.1");
3855    }
3856
3857    #[test]
3858    fn server_config_omits_tls_when_none() {
3859        // `skip_serializing_if = "Option::is_none"` keeps the on-disk shape
3860        // identical to before for the common (no-TLS) case.
3861        let server = ServerConfig::default();
3862        let value = serde_json::to_value(&server).expect("server config should serialize");
3863        let obj = value
3864            .as_object()
3865            .expect("server config serializes to object");
3866        assert!(
3867            !obj.contains_key("tls"),
3868            "tls must be omitted when None, got: {value}"
3869        );
3870    }
3871
3872    #[test]
3873    fn server_config_with_tls_roundtrips() {
3874        let server: ServerConfig = serde_json::from_value(serde_json::json!({
3875            "port": 9562,
3876            "bind": "0.0.0.0",
3877            "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
3878        }))
3879        .expect("server config with tls should deserialize");
3880
3881        let tls = server.tls.clone().expect("tls should be Some");
3882        assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
3883        assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
3884
3885        // Round-trips: tls survives a serialize → deserialize cycle.
3886        let value = serde_json::to_value(&server).expect("serialize");
3887        assert!(value.as_object().unwrap().contains_key("tls"));
3888        let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
3889        assert_eq!(back.tls, server.tls);
3890    }
3891
3892    #[test]
3893    fn access_control_without_devices_field_deserializes_back_compat() {
3894        // An old config.json `access_control` with no `devices` key must still
3895        // deserialize, leaving `devices` empty (root-password-only mode).
3896        let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
3897            "password_enabled": true,
3898            "password_hash": "deadbeef",
3899            "password_salt": "01020304",
3900        }))
3901        .expect("legacy access_control without devices should deserialize");
3902
3903        assert!(access.devices.is_empty());
3904        assert!(access.password_enabled);
3905    }
3906
3907    #[test]
3908    fn access_control_omits_devices_when_empty() {
3909        // `skip_serializing_if = "Vec::is_empty"` keeps the on-disk shape
3910        // identical for instances that never paired a device.
3911        let access = AccessControlConfig {
3912            password_enabled: true,
3913            password_hash: Some("deadbeef".to_string()),
3914            password_salt: Some("01020304".to_string()),
3915            updated_at: None,
3916            devices: Vec::new(),
3917        };
3918        let value = serde_json::to_value(&access).expect("serialize");
3919        let obj = value.as_object().expect("object");
3920        assert!(
3921            !obj.contains_key("devices"),
3922            "devices must be omitted when empty, got: {value}"
3923        );
3924    }
3925
3926    #[test]
3927    fn access_control_with_devices_roundtrips() {
3928        let device = DeviceCredential {
3929            device_id: "bamboo_0123456789ab".to_string(),
3930            label: "iPhone 15".to_string(),
3931            token_hash: "abcd".to_string(),
3932            token_salt: "ef01".to_string(),
3933            created_at: "2026-06-23T00:00:00Z".to_string(),
3934            last_used_at: None,
3935            revoked: false,
3936        };
3937        let access = AccessControlConfig {
3938            password_enabled: true,
3939            password_hash: Some("deadbeef".to_string()),
3940            password_salt: Some("01020304".to_string()),
3941            updated_at: None,
3942            devices: vec![device.clone()],
3943        };
3944        let value = serde_json::to_value(&access).expect("serialize");
3945        assert!(value.as_object().unwrap().contains_key("devices"));
3946        let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
3947        assert_eq!(back.devices, vec![device]);
3948    }
3949
3950    #[test]
3951    fn reasoning_effort_for_key_resolves_instance_id() {
3952        // Multi-instance mode: the routing key is an instance id and the effort
3953        // lives under provider_instances[<id>] — previously this fell through to
3954        // None because the resolver only matched literal provider types.
3955        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
3956            "provider_type": "copilot",
3957            "reasoning_effort": "high",
3958        }))
3959        .expect("instance config should deserialize");
3960
3961        let mut config = Config::create_default();
3962        config
3963            .provider_instances
3964            .insert("copilot-work".to_string(), instance);
3965
3966        assert_eq!(
3967            config.reasoning_effort_for_key("copilot-work"),
3968            Some(ReasoningEffort::High),
3969        );
3970    }
3971
3972    #[test]
3973    fn reasoning_effort_for_key_resolves_bodhi_legacy() {
3974        // Legacy mode: the `bodhi` provider previously had no match arm.
3975        let mut config = Config::create_default();
3976        config.providers.bodhi = Some(
3977            serde_json::from_value(serde_json::json!({
3978                "reasoning_effort": "xhigh",
3979            }))
3980            .expect("bodhi config should deserialize"),
3981        );
3982
3983        assert_eq!(
3984            config.reasoning_effort_for_key("bodhi"),
3985            Some(ReasoningEffort::Xhigh),
3986        );
3987    }
3988
3989    #[test]
3990    fn reasoning_effort_for_key_resolves_legacy_provider_type() {
3991        let mut config = Config::create_default();
3992        config.providers.openai = Some(
3993            serde_json::from_value(serde_json::json!({
3994                "api_key": "sk-test",
3995                "reasoning_effort": "low",
3996            }))
3997            .expect("openai config should deserialize"),
3998        );
3999
4000        assert_eq!(
4001            config.reasoning_effort_for_key("openai"),
4002            Some(ReasoningEffort::Low),
4003        );
4004    }
4005
4006    #[test]
4007    fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
4008        let config = Config::create_default();
4009        assert_eq!(config.reasoning_effort_for_key("nope"), None);
4010        assert_eq!(config.reasoning_effort_for_key("   "), None);
4011    }
4012
4013    struct TempHome {
4014        path: PathBuf,
4015    }
4016
4017    impl TempHome {
4018        fn new() -> Self {
4019            // `pid + nanos` alone is NOT collision-free (issue #486): every
4020            // test in this binary shares the pid, and two tests started
4021            // concurrently by the multi-threaded harness can observe the
4022            // same `SystemTime` nanos tick. Two `TempHome`s colliding on one
4023            // path means they share a directory — and the first test's
4024            // `Drop` (`remove_dir_all`) then yanks the directory out from
4025            // under the other test's in-flight `save_to_dir`, whose
4026            // tmp-file+rename dance fails with ENOENT ("Failed to write
4027            // config file ... os error 2" — `save_rotates_backup_generations`'s
4028            // exact one-off CI failure mode). A per-process atomic counter
4029            // in the name makes each instance unique unconditionally.
4030            static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
4031                std::sync::atomic::AtomicU64::new(0);
4032            let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4033            let nanos = SystemTime::now()
4034                .duration_since(UNIX_EPOCH)
4035                .expect("clock should be after unix epoch")
4036                .as_nanos();
4037            let path = std::env::temp_dir().join(format!(
4038                "chat-core-config-test-{}-{}-{}",
4039                std::process::id(),
4040                nanos,
4041                unique
4042            ));
4043            std::fs::create_dir_all(&path).expect("failed to create temp home dir");
4044            Self { path }
4045        }
4046
4047        fn set_config_json(&self, content: &str) {
4048            // Treat `path` as the Bamboo data dir and write `config.json` into it.
4049            // Tests should prefer BAMBOO_DATA_DIR over HOME to avoid global env contention.
4050            std::fs::create_dir_all(&self.path).expect("failed to create config dir");
4051            std::fs::write(self.path.join("config.json"), content)
4052                .expect("failed to write config.json");
4053        }
4054    }
4055
4056    impl Drop for TempHome {
4057        fn drop(&mut self) {
4058            let _ = std::fs::remove_dir_all(&self.path);
4059        }
4060    }
4061
4062    // Delegate to the single crate-wide test lock so env-mutating tests across
4063    // `config`, `encryption`, and `paths` serialize against one another (they
4064    // all mutate the same process-global env / static caches).
4065    fn env_lock() -> &'static Mutex<()> {
4066        crate::test_support::env_cache_lock()
4067    }
4068
4069    /// Acquire the environment lock, recovering from poison if a previous test failed
4070    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
4071        env_lock().lock().unwrap_or_else(|poisoned| {
4072            // Lock was poisoned by a previous test failure - recover it
4073            poisoned.into_inner()
4074        })
4075    }
4076
4077    #[test]
4078    fn parse_bool_env_true_values() {
4079        for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
4080            assert!(parse_bool_env(value), "value {value:?} should be true");
4081        }
4082    }
4083
4084    #[test]
4085    fn parse_bool_env_false_values() {
4086        for value in ["0", "false", "no", "off", "", "  "] {
4087            assert!(!parse_bool_env(value), "value {value:?} should be false");
4088        }
4089    }
4090
4091    #[test]
4092    fn config_new_ignores_http_proxy_env_vars() {
4093        let _lock = env_lock_acquire();
4094        let temp_home = TempHome::new();
4095        temp_home.set_config_json(
4096            r#"{
4097  "http_proxy": "",
4098  "https_proxy": ""
4099}"#,
4100        );
4101
4102        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
4103        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
4104
4105        let config = Config::from_data_dir(Some(temp_home.path.clone()));
4106
4107        assert!(
4108            config.http_proxy.is_empty(),
4109            "config should ignore HTTP_PROXY env var"
4110        );
4111        assert!(
4112            config.https_proxy.is_empty(),
4113            "config should ignore HTTPS_PROXY env var"
4114        );
4115    }
4116
4117    #[test]
4118    fn config_new_loads_config_when_proxy_fields_omitted() {
4119        let _lock = env_lock_acquire();
4120        let temp_home = TempHome::new();
4121        temp_home.set_config_json(
4122            r#"{
4123  "provider": "openai",
4124  "providers": {
4125    "openai": {
4126      "api_key": "sk-test",
4127      "model": "gpt-4o"
4128    }
4129  }
4130}"#,
4131        );
4132
4133        let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
4134        let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
4135
4136        let config = Config::from_data_dir(Some(temp_home.path.clone()));
4137
4138        assert_eq!(
4139            config
4140                .providers
4141                .openai
4142                .as_ref()
4143                .and_then(|c| c.model.as_deref()),
4144            Some("gpt-4o"),
4145            "config should load provider model from config file even when proxy fields are omitted"
4146        );
4147        assert!(config.http_proxy.is_empty());
4148        assert!(config.https_proxy.is_empty());
4149    }
4150
4151    #[test]
4152    fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
4153        let _lock = crate::test_support::env_cache_lock_acquire();
4154        let config = Config {
4155            env_vars: vec![
4156                EnvVarEntry {
4157                    name: "SECRET_TOKEN".to_string(),
4158                    value: "top-secret".to_string(),
4159                    secret: true,
4160                    value_encrypted: None,
4161                    description: Some("Service token".to_string()),
4162                },
4163                EnvVarEntry {
4164                    name: "API_BASE".to_string(),
4165                    value: "https://internal.example".to_string(),
4166                    secret: false,
4167                    value_encrypted: None,
4168                    description: Some("Internal API base".to_string()),
4169                },
4170            ],
4171            ..Default::default()
4172        };
4173
4174        config.publish_env_vars();
4175
4176        let injected = Config::current_env_vars();
4177        assert_eq!(
4178            injected.get("SECRET_TOKEN").map(String::as_str),
4179            Some("top-secret")
4180        );
4181        assert_eq!(
4182            injected.get("API_BASE").map(String::as_str),
4183            Some("https://internal.example")
4184        );
4185
4186        let prompt_safe = Config::current_prompt_safe_env_vars();
4187        assert_eq!(prompt_safe.len(), 2);
4188        assert!(prompt_safe.iter().any(|entry| {
4189            entry.name == "SECRET_TOKEN"
4190                && entry.secret
4191                && entry.description.as_deref() == Some("Service token")
4192        }));
4193        assert!(prompt_safe.iter().any(|entry| {
4194            entry.name == "API_BASE"
4195                && !entry.secret
4196                && entry.description.as_deref() == Some("Internal API base")
4197        }));
4198        assert!(!prompt_safe
4199            .iter()
4200            .any(|entry| entry.name.contains("top-secret")));
4201        assert!(!prompt_safe.iter().any(|entry| {
4202            entry
4203                .description
4204                .as_deref()
4205                .is_some_and(|value| value.contains("https://internal.example"))
4206        }));
4207    }
4208
4209    #[test]
4210    fn from_data_dir_without_publish_does_not_clobber_global_cache() {
4211        let _lock = crate::test_support::env_cache_lock_acquire();
4212
4213        // Seed the global cache with a marker "owned" by the live config.
4214        Config {
4215            env_vars: vec![EnvVarEntry {
4216                name: "BAMBOO_CACHE_OWNER_40".to_string(),
4217                value: "live".to_string(),
4218                secret: false,
4219                value_encrypted: None,
4220                description: None,
4221            }],
4222            ..Default::default()
4223        }
4224        .publish_env_vars();
4225        assert_eq!(
4226            Config::current_env_vars()
4227                .get("BAMBOO_CACHE_OWNER_40")
4228                .map(String::as_str),
4229            Some("live")
4230        );
4231
4232        // A config.json on disk sets the SAME var to a different (stale) value.
4233        let temp = TempHome::new();
4234        temp.set_config_json(
4235            &serde_json::json!({
4236                "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
4237            })
4238            .to_string(),
4239        );
4240
4241        // Non-publishing load reads the disk value into the returned Config but
4242        // must NOT touch the global cache.
4243        let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4244        assert_eq!(
4245            loaded
4246                .env_vars
4247                .iter()
4248                .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
4249                .map(|e| e.value.as_str()),
4250            Some("stale-disk"),
4251            "the returned Config holds the disk value"
4252        );
4253        assert_eq!(
4254            Config::current_env_vars()
4255                .get("BAMBOO_CACHE_OWNER_40")
4256                .map(String::as_str),
4257            Some("live"),
4258            "but the global cache is UNTOUCHED — no clobber (#40)"
4259        );
4260
4261        // Contrast: the publishing variant DOES clobber the cache.
4262        let _ = Config::from_data_dir(Some(temp.path.clone()));
4263        assert_eq!(
4264            Config::current_env_vars()
4265                .get("BAMBOO_CACHE_OWNER_40")
4266                .map(String::as_str),
4267            Some("stale-disk"),
4268            "the publishing loader clobbers the cache (contrast)"
4269        );
4270    }
4271
4272    fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
4273        std::fs::read_dir(dir)
4274            .unwrap()
4275            .filter_map(|e| e.ok())
4276            .any(|e| {
4277                e.file_name()
4278                    .to_string_lossy()
4279                    .contains("config.json.corrupted.")
4280            })
4281    }
4282
4283    #[test]
4284    fn corrupt_config_recovered_from_backup_and_quarantined() {
4285        let temp = TempHome::new();
4286        // Last-known-good backup with a distinctive value.
4287        std::fs::write(
4288            temp.path.join("config.json.bak"),
4289            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
4290        )
4291        .unwrap();
4292        // Corrupt primary config.json.
4293        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
4294
4295        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4296        assert_eq!(
4297            config.http_proxy, "http://from-backup",
4298            "recovered from config.json.bak instead of losing all config"
4299        );
4300        assert!(
4301            dir_has_quarantine_file(&temp.path),
4302            "corrupt config.json was quarantined (preserved), not discarded"
4303        );
4304    }
4305
4306    #[test]
4307    fn corrupt_config_without_backup_quarantines_then_defaults() {
4308        let temp = TempHome::new();
4309        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
4310
4311        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4312        assert!(
4313            config.http_proxy.is_empty(),
4314            "no backup -> falls back to defaults"
4315        );
4316        assert!(
4317            dir_has_quarantine_file(&temp.path),
4318            "corrupt config.json is quarantined even when there's no backup"
4319        );
4320    }
4321
4322    #[test]
4323    fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
4324        let temp = TempHome::new();
4325        // A valid JSON OBJECT, but `env_vars` is the wrong type (string, not array)
4326        // so STRICT parse fails. There is NO config.json.bak, so recovery must come
4327        // from field-level salvage: `http_proxy` is valid and must survive; the bad
4328        // `env_vars` resets to its default.
4329        temp.set_config_json(
4330            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
4331        );
4332
4333        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4334        assert_eq!(
4335            config.http_proxy, "http://salvaged",
4336            "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
4337        );
4338        assert!(
4339            config.env_vars.is_empty(),
4340            "the corrupt field reset to its default instead of failing the whole load"
4341        );
4342        assert!(
4343            dir_has_quarantine_file(&temp.path),
4344            "the corrupt config.json was still quarantined for inspection"
4345        );
4346    }
4347
4348    #[test]
4349    fn salvage_preferred_over_backup_for_most_recent_intent() {
4350        let temp = TempHome::new();
4351        // An OLDER last-known-good backup...
4352        std::fs::write(
4353            temp.path.join("config.json.bak"),
4354            serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
4355        )
4356        .unwrap();
4357        // ...and a NEWER config that is corrupt but field-salvageable.
4358        temp.set_config_json(
4359            r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
4360        );
4361
4362        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4363        assert_eq!(
4364            config.http_proxy, "http://new-salvaged",
4365            "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
4366        );
4367    }
4368
4369    #[test]
4370    fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
4371        let temp = TempHome::new();
4372        // Backup carries TWO good values.
4373        std::fs::write(
4374            temp.path.join("config.json.bak"),
4375            serde_json::json!({
4376                "http_proxy": "http://old-from-backup",
4377                "https_proxy": "https://kept-from-backup",
4378            })
4379            .to_string(),
4380        )
4381        .unwrap();
4382        // The corrupt file updates http_proxy (newer), leaves https_proxy untouched,
4383        // and has one wrong-type field.
4384        temp.set_config_json(
4385            r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
4386        );
4387
4388        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4389        // Best of both: the corrupt file's newer valid edit wins where it set one...
4390        assert_eq!(
4391            config.http_proxy, "http://newer-edit",
4392            "the corrupt file's newer valid edit is applied"
4393        );
4394        // ...and the backup's value survives for fields the corrupt file didn't fix.
4395        assert_eq!(
4396            config.https_proxy, "https://kept-from-backup",
4397            "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
4398        );
4399    }
4400
4401    #[test]
4402    fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
4403        let temp = TempHome::new();
4404        // Not even a JSON object -> nothing field-wise to salvage -> must fall
4405        // through to the .bak (the pre-#135 behavior is preserved).
4406        std::fs::write(
4407            temp.path.join("config.json.bak"),
4408            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
4409        )
4410        .unwrap();
4411        std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
4412
4413        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4414        assert_eq!(
4415            config.http_proxy, "http://from-backup",
4416            "garbage (non-object) config skips salvage and recovers from .bak"
4417        );
4418    }
4419
4420    #[test]
4421    fn quarantine_files_are_capped_to_newest_n() {
4422        let temp = TempHome::new();
4423        let config_path = temp.path.join("config.json");
4424        std::fs::write(&config_path, "{}").unwrap();
4425
4426        // Drop more quarantines than the cap; each call sleeps so nanos (the name)
4427        // and mtime (the prune sort key) are distinct.
4428        for _ in 0..(QUARANTINE_KEEP + 3) {
4429            quarantine_corrupt_config(&config_path);
4430            std::thread::sleep(std::time::Duration::from_millis(3));
4431        }
4432
4433        let count = std::fs::read_dir(&temp.path)
4434            .unwrap()
4435            .filter_map(|e| e.ok())
4436            .filter(|e| {
4437                e.file_name()
4438                    .to_string_lossy()
4439                    .starts_with("config.json.corrupted.")
4440            })
4441            .count();
4442        assert_eq!(
4443            count, QUARANTINE_KEEP,
4444            "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
4445        );
4446    }
4447
4448    #[test]
4449    fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
4450        let temp = TempHome::new();
4451        // Primary AND the freshest .bak are corrupt; an older generation is good.
4452        std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
4453        std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
4454        std::fs::write(
4455            temp.path.join("config.json.bak.1"),
4456            serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
4457        )
4458        .unwrap();
4459
4460        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4461        assert_eq!(
4462            config.http_proxy, "http://from-gen-1",
4463            "recovered from .bak.1 when both config.json and .bak are corrupt"
4464        );
4465    }
4466
4467    #[test]
4468    fn save_rotates_backup_generations() {
4469        let temp = TempHome::new();
4470        let path = temp.path.join("config.json");
4471        // v1 is the existing on-disk config.
4472        std::fs::write(
4473            &path,
4474            serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
4475        )
4476        .unwrap();
4477
4478        let mut cfg = Config::create_default();
4479        // Save 1: backs up the existing v1 -> .bak, writes v2.
4480        cfg.http_proxy = "http://proxy-v2".to_string();
4481        cfg.save_to_dir(temp.path.clone()).unwrap();
4482        // Save 2: existing (v2) is parseable -> rotate .bak(v1) -> .bak.1, .bak = v2.
4483        cfg.http_proxy = "http://proxy-v3".to_string();
4484        cfg.save_to_dir(temp.path.clone()).unwrap();
4485
4486        let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
4487        let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
4488        assert!(
4489            bak.contains("proxy-v2"),
4490            ".bak holds the previous generation (v2)"
4491        );
4492        assert!(
4493            bak1.contains("proxy-v1"),
4494            ".bak.1 holds the older rotated generation (v1)"
4495        );
4496    }
4497
4498    #[test]
4499    fn save_backs_up_existing_config() {
4500        let temp = TempHome::new();
4501        // Existing (old) config on disk.
4502        std::fs::write(
4503            temp.path.join("config.json"),
4504            serde_json::json!({ "http_proxy": "http://old" }).to_string(),
4505        )
4506        .unwrap();
4507
4508        let mut config = Config::create_default();
4509        config.http_proxy = "http://new".to_string();
4510        config
4511            .save_to_dir(temp.path.clone())
4512            .expect("save succeeds");
4513
4514        let backup =
4515            std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
4516        assert!(
4517            backup.contains("http://old"),
4518            "config.json.bak holds the PREVIOUS config (last-known-good)"
4519        );
4520        let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
4521        assert!(
4522            current.contains("http://new"),
4523            "config.json holds the new config"
4524        );
4525    }
4526
4527    #[test]
4528    fn save_does_not_overwrite_good_backup_with_corrupt_config() {
4529        let temp = TempHome::new();
4530        // A good last-known-good backup...
4531        std::fs::write(
4532            temp.path.join("config.json.bak"),
4533            serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
4534        )
4535        .unwrap();
4536        // ...but the on-disk config.json is corrupt (as it would be right after an
4537        // in-memory recovery, before any clean save).
4538        std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
4539
4540        let mut config = Config::create_default();
4541        config.http_proxy = "http://new".to_string();
4542        config
4543            .save_to_dir(temp.path.clone())
4544            .expect("save succeeds");
4545
4546        // The good .bak must NOT have been clobbered by the corrupt config.json.
4547        let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
4548        assert!(
4549            backup.contains("http://good-bak"),
4550            "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
4551        );
4552    }
4553
4554    // ── config-corruption recovery confirmation gate (#153) ───────────────
4555
4556    #[test]
4557    fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
4558        let temp = TempHome::new();
4559        std::fs::write(
4560            temp.path.join("config.json.bak"),
4561            serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
4562        )
4563        .unwrap();
4564        let corrupt_bytes = "{ not valid json ";
4565        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
4566
4567        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4568        let status = config
4569            .recovery_status()
4570            .expect("a corrupt load must set a pending recovery status");
4571        assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
4572        assert_eq!(
4573            status.source,
4574            ConfigRecoverySource::Backup { generation: 0 },
4575            "recovered from generation-0 (.bak)"
4576        );
4577        let quarantine_path = status
4578            .quarantine_path
4579            .as_ref()
4580            .expect("quarantine copy should have succeeded");
4581        assert_eq!(
4582            std::fs::read_to_string(quarantine_path).unwrap(),
4583            corrupt_bytes,
4584            "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
4585        );
4586        assert_eq!(
4587            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
4588            corrupt_bytes,
4589            "the original config.json itself is untouched by the load (only copied, not moved)"
4590        );
4591    }
4592
4593    #[test]
4594    fn recovery_status_set_from_salvage_lists_recovered_fields() {
4595        let temp = TempHome::new();
4596        temp.set_config_json(
4597            r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
4598        );
4599
4600        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4601        let status = config.recovery_status().expect("pending recovery");
4602        assert!(!status.confirmed);
4603        match &status.source {
4604            ConfigRecoverySource::Salvaged { fields } => {
4605                assert!(
4606                    fields.iter().any(|f| f == "http_proxy"),
4607                    "salvaged fields should list the recovered key: {fields:?}"
4608                );
4609            }
4610            other => panic!("expected Salvaged source, got {other:?}"),
4611        }
4612    }
4613
4614    #[test]
4615    fn recovery_status_set_from_defaults_when_nothing_salvageable() {
4616        let temp = TempHome::new();
4617        // Not a JSON object at all -> salvage impossible; no .bak -> defaults.
4618        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
4619
4620        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4621        let status = config.recovery_status().expect("pending recovery");
4622        assert!(!status.confirmed);
4623        assert_eq!(status.source, ConfigRecoverySource::Defaults);
4624    }
4625
4626    #[test]
4627    fn clean_load_never_sets_recovery_status() {
4628        let temp = TempHome::new();
4629        temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
4630
4631        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4632        assert!(
4633            config.recovery_status().is_none(),
4634            "a config.json that parses cleanly must never carry a pending recovery status"
4635        );
4636    }
4637
4638    #[test]
4639    fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
4640        let temp = TempHome::new();
4641        let corrupt_bytes = "}}} broken";
4642        std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
4643
4644        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4645        assert!(config.recovery_status().is_some());
4646
4647        let err = config
4648            .save_to_dir(temp.path.clone())
4649            .expect_err("save must refuse while recovery is unconfirmed");
4650        assert!(
4651            err.to_string().contains("recovered from corruption")
4652                || err.to_string().contains("confirm"),
4653            "error should explain the refused overwrite: {err}"
4654        );
4655
4656        // The corrupt original on disk must be BYTE FOR BYTE unchanged — the
4657        // refused save must not have touched it at all.
4658        assert_eq!(
4659            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
4660            corrupt_bytes,
4661            "a refused save must leave the corrupt original untouched"
4662        );
4663    }
4664
4665    #[test]
4666    fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
4667        let temp = TempHome::new();
4668        // Simulates a crash mid-write: valid JSON prefix, abruptly cut off.
4669        let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
4670        std::fs::write(temp.path.join("config.json"), truncated).unwrap();
4671
4672        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4673        let status = config.recovery_status().expect("pending recovery");
4674        let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
4675        assert_eq!(
4676            std::fs::read_to_string(quarantine_path).unwrap(),
4677            truncated,
4678            "truncated original preserved byte for byte in quarantine"
4679        );
4680
4681        let err = config.save_to_dir(temp.path.clone());
4682        assert!(err.is_err(), "unconfirmed recovery must refuse to save");
4683        assert_eq!(
4684            std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
4685            truncated,
4686            "the half-written original stays exactly as it was after a refused save"
4687        );
4688    }
4689
4690    #[test]
4691    fn confirm_recovery_allows_the_next_save() {
4692        let temp = TempHome::new();
4693        std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
4694
4695        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4696        assert!(config.recovery_status().is_some());
4697
4698        config.confirm_recovery();
4699        assert!(
4700            config.recovery_status().is_some_and(|s| s.confirmed),
4701            "confirm_recovery flips the flag but keeps the status around"
4702        );
4703
4704        config
4705            .save_to_dir(temp.path.clone())
4706            .expect("save must succeed once the recovery is confirmed");
4707    }
4708
4709    #[test]
4710    fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
4711        let temp = TempHome::new();
4712        std::fs::write(
4713            temp.path.join("config.json"),
4714            r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
4715        )
4716        .unwrap();
4717
4718        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4719        assert!(config.recovery_status().is_some());
4720        let quarantine_path = config
4721            .recovery_status()
4722            .unwrap()
4723            .quarantine_path
4724            .clone()
4725            .unwrap();
4726
4727        config
4728            .confirm_recovery_and_save_to_dir(temp.path.clone())
4729            .expect("confirm+save should succeed");
4730
4731        assert!(
4732            config.recovery_status().is_none(),
4733            "the pending flag is cleared once the recovery is confirmed and persisted"
4734        );
4735        let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
4736        assert!(
4737            on_disk.contains("http://recovered"),
4738            "config.json now holds the recovered (salvaged) state"
4739        );
4740        // The quarantine copy of the original corrupt file must still exist,
4741        // untouched, even after the recovery is confirmed and persisted.
4742        assert!(
4743            quarantine_path.exists(),
4744            "the quarantined original survives confirmation — it's never deleted"
4745        );
4746    }
4747
4748    #[test]
4749    fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
4750        let temp = TempHome::new();
4751        let mut config = Config::create_default();
4752        let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
4753        assert!(
4754            err.is_err(),
4755            "confirming a recovery that was never pending must error, not silently succeed"
4756        );
4757    }
4758
4759    // ── connect.json split (#455) ────────────────────────────────────────
4760
4761    fn connect_platform_with_encrypted(
4762        platform_type: &str,
4763        token_encrypted: &str,
4764    ) -> ConnectPlatformConfig {
4765        ConnectPlatformConfig {
4766            id: None,
4767            platform_type: platform_type.to_string(),
4768            token: None,
4769            token_encrypted: Some(token_encrypted.to_string()),
4770            app_id: None,
4771            app_secret: None,
4772            app_secret_encrypted: None,
4773            domain: None,
4774            allow_from: vec!["user-1".to_string()],
4775            admin_from: Vec::new(),
4776        }
4777    }
4778
4779    fn connect_json_path(temp: &TempHome) -> PathBuf {
4780        temp.path.join("connect.json")
4781    }
4782
4783    #[test]
4784    fn save_splits_connect_into_sibling_connect_json() {
4785        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4786        let temp = TempHome::new();
4787
4788        let mut config = Config::create_default();
4789        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
4790        config.connect.platforms[0].token = Some("plain-bot-token".to_string());
4791
4792        config
4793            .save_to_dir(temp.path.clone())
4794            .expect("save succeeds");
4795
4796        let config_json: serde_json::Value =
4797            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
4798                .unwrap();
4799        assert!(
4800            config_json.get("connect").is_none(),
4801            "config.json must not carry the `connect` key after a save"
4802        );
4803
4804        let connect_json: serde_json::Value =
4805            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4806                .unwrap();
4807        assert_eq!(connect_json["platforms"][0]["type"], "telegram");
4808        assert!(
4809            connect_json["platforms"][0]["token_encrypted"]
4810                .as_str()
4811                .is_some_and(|v| !v.is_empty()),
4812            "the token is persisted in its encrypted form in connect.json"
4813        );
4814        assert!(
4815            connect_json["platforms"][0].get("token").is_none(),
4816            "the plaintext token is never persisted (skip_serializing)"
4817        );
4818    }
4819
4820    // ── stable connect.platforms id (#496) ───────────────────────────────
4821
4822    #[test]
4823    fn save_assigns_a_missing_connect_platform_id() {
4824        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4825        let temp = TempHome::new();
4826
4827        let mut config = Config::create_default();
4828        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
4829        assert!(
4830            config.connect.platforms[0].id.is_none(),
4831            "precondition: the entry starts without an id"
4832        );
4833
4834        config
4835            .save_to_dir(temp.path.clone())
4836            .expect("save succeeds");
4837
4838        let connect_json: serde_json::Value =
4839            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4840                .unwrap();
4841        let persisted_id = connect_json["platforms"][0]["id"]
4842            .as_str()
4843            .expect("save_to_dir must backfill a missing id onto the persisted entry");
4844        assert!(!persisted_id.is_empty());
4845
4846        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4847        assert_eq!(
4848            reloaded.connect.platforms[0].id.as_deref(),
4849            Some(persisted_id),
4850            "the assigned id round-trips through a reload"
4851        );
4852    }
4853
4854    #[test]
4855    fn save_never_reassigns_an_existing_connect_platform_id() {
4856        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4857        let temp = TempHome::new();
4858
4859        let mut config = Config::create_default();
4860        let mut platform = connect_platform_with_encrypted("telegram", "cipher");
4861        platform.id = Some("stable-id-123".to_string());
4862        config.connect.platforms = vec![platform];
4863
4864        config
4865            .save_to_dir(temp.path.clone())
4866            .expect("first save succeeds");
4867        // Save again (e.g. an unrelated settings change) — the id must not change.
4868        config
4869            .save_to_dir(temp.path.clone())
4870            .expect("second save succeeds");
4871
4872        let connect_json: serde_json::Value =
4873            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4874                .unwrap();
4875        assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
4876    }
4877
4878    #[test]
4879    fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
4880        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
4881        let temp = TempHome::new();
4882
4883        let mut config = Config::create_default();
4884        config.connect.platforms = vec![
4885            connect_platform_with_encrypted("telegram", "cipher-a"),
4886            connect_platform_with_encrypted("telegram", "cipher-b"),
4887        ];
4888
4889        config
4890            .save_to_dir(temp.path.clone())
4891            .expect("save succeeds");
4892
4893        let connect_json: serde_json::Value =
4894            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
4895                .unwrap();
4896        let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
4897        let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
4898        assert_ne!(
4899            id_a, id_b,
4900            "two entries sharing platform_type must still get distinct ids"
4901        );
4902    }
4903
4904    #[test]
4905    fn load_never_assigns_or_persists_an_id_by_itself() {
4906        let temp = TempHome::new();
4907        std::fs::write(
4908            connect_json_path(&temp),
4909            serde_json::json!({
4910                "platforms": [
4911                    { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
4912                ]
4913            })
4914            .to_string(),
4915        )
4916        .unwrap();
4917        let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
4918
4919        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4920
4921        assert!(
4922            config.connect.platforms[0].id.is_none(),
4923            "load alone must not backfill an id in memory"
4924        );
4925        let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
4926        assert_eq!(
4927            connect_json_before, connect_json_after,
4928            "load must never rewrite connect.json on disk just to backfill an id (#493)"
4929        );
4930    }
4931
4932    #[test]
4933    fn load_merges_connect_json_into_config() {
4934        let temp = TempHome::new();
4935        std::fs::write(
4936            connect_json_path(&temp),
4937            serde_json::json!({
4938                "platforms": [
4939                    { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
4940                ]
4941            })
4942            .to_string(),
4943        )
4944        .unwrap();
4945
4946        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4947        assert_eq!(config.connect.platforms.len(), 1);
4948        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
4949        assert_eq!(
4950            config.connect.platforms[0].token_encrypted.as_deref(),
4951            Some("cipher-abc")
4952        );
4953    }
4954
4955    #[test]
4956    fn load_without_connect_json_yields_empty_inert_connect_config() {
4957        let temp = TempHome::new();
4958        temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
4959
4960        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4961        assert!(
4962            config.connect.platforms.is_empty(),
4963            "no connect.json and no legacy key -> empty/inert connect config"
4964        );
4965        assert!(
4966            !connect_json_path(&temp).exists(),
4967            "load must not create connect.json when there is nothing to migrate"
4968        );
4969    }
4970
4971    #[test]
4972    fn migration_adopts_legacy_connect_key_and_writes_both_files() {
4973        let temp = TempHome::new();
4974        // Legacy state (#453): connect lives inline in config.json, no connect.json yet.
4975        temp.set_config_json(
4976            &serde_json::json!({
4977                "http_proxy": "http://keep-me",
4978                "connect": {
4979                    "platforms": [
4980                        { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
4981                    ]
4982                }
4983            })
4984            .to_string(),
4985        );
4986
4987        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
4988
4989        // In-memory: legacy value adopted.
4990        assert_eq!(config.connect.platforms.len(), 1);
4991        assert_eq!(
4992            config.connect.platforms[0].token_encrypted.as_deref(),
4993            Some("legacy-cipher")
4994        );
4995        // An unrelated field from the same load survives the migration rewrite.
4996        assert_eq!(config.http_proxy, "http://keep-me");
4997
4998        // On disk: connect.json was created...
4999        assert!(
5000            connect_json_path(&temp).exists(),
5001            "migration proactively creates connect.json"
5002        );
5003        let connect_json: serde_json::Value =
5004            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
5005                .unwrap();
5006        assert_eq!(
5007            connect_json["platforms"][0]["token_encrypted"], "legacy-cipher",
5008            "the encrypted value is preserved (encrypted form intact) by the migration"
5009        );
5010
5011        // ...and config.json was rewritten without the `connect` key.
5012        let config_json: serde_json::Value =
5013            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
5014                .unwrap();
5015        assert!(
5016            config_json.get("connect").is_none(),
5017            "config.json is rewritten without the legacy `connect` key"
5018        );
5019    }
5020
5021    /// #457: the legacy-key migration must be a NARROW write (strip `connect`
5022    /// from config.json + write connect.json) — not the full `save_to_dir`,
5023    /// which would re-encrypt every OTHER secret in config.json and rotate a
5024    /// `config.json.bak` generation as a load-time side effect. This matters
5025    /// most for a purely READ-ONLY command (e.g. `bamboo config get`) run on a
5026    /// machine that still has the legacy `connect` key: it must not silently
5027    /// rewrite/re-encrypt unrelated secrets or spin up a backup.
5028    #[test]
5029    fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
5030        let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
5031        let temp = TempHome::new();
5032
5033        let original_api_key_encrypted =
5034            crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
5035        temp.set_config_json(
5036            &serde_json::json!({
5037                "providers": {
5038                    "openai": {
5039                        "api_key_encrypted": original_api_key_encrypted,
5040                    }
5041                },
5042                "connect": {
5043                    "platforms": [
5044                        { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
5045                    ]
5046                }
5047            })
5048            .to_string(),
5049        );
5050
5051        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5052        assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
5053
5054        // No `config.json.bak` — the narrow write does not rotate backups the
5055        // way a full `save_to_dir` would.
5056        assert!(
5057            !temp.path.join("config.json.bak").exists(),
5058            "a read-only load migrating a legacy `connect` key must not rotate \
5059             config.json backups"
5060        );
5061
5062        // The unrelated provider secret's ciphertext is byte-for-byte
5063        // unchanged — proof it was never decrypted+re-encrypted (encryption
5064        // uses a random nonce per call, so any re-encryption would change the
5065        // bytes even for the same plaintext).
5066        let config_json: serde_json::Value =
5067            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
5068                .unwrap();
5069        assert_eq!(
5070            config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
5071            "an unrelated secret's ciphertext must not be touched by the connect \
5072             migration's narrow write"
5073        );
5074        assert!(
5075            config_json.get("connect").is_none(),
5076            "config.json is still rewritten without the legacy `connect` key"
5077        );
5078    }
5079
5080    #[test]
5081    fn both_files_present_connect_json_wins() {
5082        let temp = TempHome::new();
5083        temp.set_config_json(
5084            &serde_json::json!({
5085                "connect": {
5086                    "platforms": [
5087                        { "type": "telegram", "token_encrypted": "stale-config-json-cipher" }
5088                    ]
5089                }
5090            })
5091            .to_string(),
5092        );
5093        std::fs::write(
5094            connect_json_path(&temp),
5095            serde_json::json!({
5096                "platforms": [
5097                    { "type": "telegram", "token_encrypted": "authoritative-cipher" }
5098                ]
5099            })
5100            .to_string(),
5101        )
5102        .unwrap();
5103
5104        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5105        assert_eq!(
5106            config.connect.platforms[0].token_encrypted.as_deref(),
5107            Some("authoritative-cipher"),
5108            "connect.json wins over a stale legacy config.json key"
5109        );
5110    }
5111
5112    /// #457: when both files are present, the superseded `connect` key in
5113    /// config.json must be stripped PROACTIVELY on load — not left to linger
5114    /// until the next natural save, which spreads token ciphertext across two
5115    /// files for longer than necessary.
5116    #[test]
5117    fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
5118        let temp = TempHome::new();
5119        temp.set_config_json(
5120            &serde_json::json!({
5121                "http_proxy": "http://keep-me",
5122                "connect": {
5123                    "platforms": [
5124                        { "type": "telegram", "token_encrypted": "stale-config-json-cipher" }
5125                    ]
5126                }
5127            })
5128            .to_string(),
5129        );
5130        std::fs::write(
5131            connect_json_path(&temp),
5132            serde_json::json!({
5133                "platforms": [
5134                    { "type": "telegram", "token_encrypted": "authoritative-cipher" }
5135                ]
5136            })
5137            .to_string(),
5138        )
5139        .unwrap();
5140
5141        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5142        assert_eq!(
5143            config.connect.platforms[0].token_encrypted.as_deref(),
5144            Some("authoritative-cipher")
5145        );
5146        // Unrelated field survives the strip.
5147        assert_eq!(config.http_proxy, "http://keep-me");
5148
5149        let config_json: serde_json::Value =
5150            serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
5151                .unwrap();
5152        assert!(
5153            config_json.get("connect").is_none(),
5154            "the stale legacy `connect` key must be stripped from config.json \
5155             immediately on load, not left for the next natural save"
5156        );
5157    }
5158
5159    /// #468 (follow-up to #457): a `.bak` generation that predates the #455
5160    /// split can still carry the legacy embedded `connect` sub-tree even
5161    /// after the LIVE config.json has long since been migrated (a clean
5162    /// config.json here, with no `connect` key at all, proves the sweep does
5163    /// not depend on the current-load migration path having just fired).
5164    /// Only the tainted generation is rewritten; every other key in it
5165    /// survives, and the rewrite strips exactly the `connect` key.
5166    #[test]
5167    fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
5168        let temp = TempHome::new();
5169        temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
5170        std::fs::write(
5171            temp.path.join("config.json.bak"),
5172            serde_json::json!({
5173                "http_proxy": "http://old",
5174                "connect": {
5175                    "platforms": [
5176                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
5177                    ]
5178                }
5179            })
5180            .to_string(),
5181        )
5182        .unwrap();
5183
5184        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5185        // The live config is unaffected — connect.json never existed and
5186        // config.json never had the key, so in-memory connect stays empty.
5187        assert!(config.connect.platforms.is_empty());
5188
5189        let bak: serde_json::Value = serde_json::from_str(
5190            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
5191        )
5192        .unwrap();
5193        assert!(
5194            bak.get("connect").is_none(),
5195            "the legacy `connect` key must be stripped from the tainted .bak generation"
5196        );
5197        assert_eq!(
5198            bak["http_proxy"], "http://old",
5199            "every other key in the .bak generation survives the scrub byte-for-byte in content"
5200        );
5201    }
5202
5203    /// The sweep must touch EVERY rotated generation that carries the legacy
5204    /// key, not just `.bak` — an upgraded instance can have the taint several
5205    /// generations deep depending on how many saves happened since #455/#457
5206    /// shipped but before this fix.
5207    #[test]
5208    fn scrub_reaches_all_rotated_generations() {
5209        let temp = TempHome::new();
5210        temp.set_config_json(&serde_json::json!({}).to_string());
5211        for (gen_suffix, cipher) in [
5212            ("config.json.bak", "cipher-gen0"),
5213            ("config.json.bak.1", "cipher-gen1"),
5214            ("config.json.bak.2", "cipher-gen2"),
5215        ] {
5216            std::fs::write(
5217                temp.path.join(gen_suffix),
5218                serde_json::json!({
5219                    "connect": {
5220                        "platforms": [
5221                            { "type": "telegram", "token_encrypted": cipher }
5222                        ]
5223                    }
5224                })
5225                .to_string(),
5226            )
5227            .unwrap();
5228        }
5229
5230        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5231
5232        for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
5233            let value: serde_json::Value =
5234                serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
5235                    .unwrap();
5236            assert!(
5237                value.get("connect").is_none(),
5238                "{gen_suffix} must have its legacy `connect` key stripped"
5239            );
5240        }
5241    }
5242
5243    /// A `.bak` generation with NO legacy `connect` key must be left
5244    /// completely untouched by the sweep — not even a byte-identical
5245    /// rewrite — preserving the file's bytes/mtime exactly. This is the
5246    /// overwhelming common case (any backup created after #455/#457 shipped)
5247    /// and the whole point of the surgical, only-touch-what's-tainted
5248    /// approach: `.bak` files are the user's recovery net (#493) and
5249    /// shouldn't be churned by an unrelated sweep.
5250    #[test]
5251    fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
5252        let temp = TempHome::new();
5253        temp.set_config_json(&serde_json::json!({}).to_string());
5254        let bak_path = temp.path.join("config.json.bak");
5255        std::fs::write(
5256            &bak_path,
5257            serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
5258        )
5259        .unwrap();
5260
5261        let before_bytes = std::fs::read(&bak_path).unwrap();
5262        let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
5263
5264        // A tiny sleep would make an mtime-changed assertion more robust, but
5265        // even without one, a same-mtime filesystem is the STRONGER
5266        // guarantee of "no write happened" — good enough on its own.
5267        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5268
5269        let after_bytes = std::fs::read(&bak_path).unwrap();
5270        let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
5271        assert_eq!(
5272            before_bytes, after_bytes,
5273            "a .bak generation without a legacy `connect` key must not be rewritten at all"
5274        );
5275        assert_eq!(
5276            before_mtime, after_mtime,
5277            "no write means no mtime change either"
5278        );
5279    }
5280
5281    /// An unparsable `.bak` generation (corrupt/foreign content) must be
5282    /// skipped, not deleted and not guessed at — it's left exactly as found
5283    /// so an operator can inspect it by hand, matching the same fail-safe
5284    /// posture as the rest of the backup/quarantine machinery.
5285    #[test]
5286    fn scrub_skips_unparsable_backup_without_deleting_it() {
5287        let temp = TempHome::new();
5288        temp.set_config_json(&serde_json::json!({}).to_string());
5289        std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
5290
5291        let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5292
5293        let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
5294        assert_eq!(
5295            content, "{ not valid json",
5296            "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
5297        );
5298    }
5299
5300    /// A missing generation (e.g. only `.bak` exists, no `.bak.1`/`.bak.2`
5301    /// yet) must not trip an error — it's the common case for a young
5302    /// install and the sweep should just skip straight past it.
5303    #[test]
5304    fn scrub_tolerates_missing_generations() {
5305        let temp = TempHome::new();
5306        temp.set_config_json(&serde_json::json!({}).to_string());
5307        // No .bak files at all.
5308        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5309        assert!(config.connect.platforms.is_empty());
5310        assert!(!temp.path.join("config.json.bak").exists());
5311    }
5312
5313    /// The scrub sweep must not interfere with normal backup rotation on
5314    /// subsequent saves — rotation keeps working exactly as before.
5315    #[test]
5316    fn scrub_does_not_break_backup_rotation() {
5317        let temp = TempHome::new();
5318        std::fs::write(
5319            temp.path.join("config.json"),
5320            serde_json::json!({
5321                "http_proxy": "http://proxy-v1",
5322                "connect": {
5323                    "platforms": [
5324                        { "type": "telegram", "token_encrypted": "legacy-cipher" }
5325                    ]
5326                }
5327            })
5328            .to_string(),
5329        )
5330        .unwrap();
5331        std::fs::write(
5332            temp.path.join("config.json.bak"),
5333            serde_json::json!({
5334                "http_proxy": "http://proxy-v0",
5335                "connect": {
5336                    "platforms": [
5337                        { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
5338                    ]
5339                }
5340            })
5341            .to_string(),
5342        )
5343        .unwrap();
5344
5345        // Load triggers: migration of the live legacy key + the .bak sweep.
5346        let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5347        let bak: serde_json::Value = serde_json::from_str(
5348            &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
5349        )
5350        .unwrap();
5351        assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
5352
5353        // Rotation still works on a subsequent save: v_current -> .bak,
5354        // .bak(old) -> .bak.1.
5355        config.http_proxy = "http://proxy-v2".to_string();
5356        config.save_to_dir(temp.path.clone()).unwrap();
5357
5358        let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
5359        assert!(
5360            new_bak.contains("proxy-v1"),
5361            ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
5362        );
5363        let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
5364        assert!(
5365            new_bak1.contains("proxy-v0"),
5366            ".bak.1 holds the scrubbed older generation after rotation"
5367        );
5368        assert!(
5369            !new_bak1.contains("legacy-bak-cipher"),
5370            "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
5371             stripped secret"
5372        );
5373    }
5374
5375    #[test]
5376    fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
5377        let temp = TempHome::new();
5378        temp.set_config_json("{}");
5379        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
5380
5381        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5382        assert!(
5383            config.connect.platforms.is_empty(),
5384            "corrupt connect.json fails SAFE to an empty/inert connect config"
5385        );
5386
5387        let backup = connect_json_path(&temp).with_extension("json.bak");
5388        assert!(
5389            backup.exists(),
5390            "the corrupt connect.json is quarantined to connect.json.bak"
5391        );
5392        assert!(
5393            std::fs::read_to_string(backup)
5394                .unwrap()
5395                .contains("not valid json"),
5396            "the quarantined copy holds the bad content"
5397        );
5398        // #457: quarantine MOVES the corrupt file rather than copying it, so
5399        // the data dir doesn't end up with two copies of the same corrupt
5400        // content (the live `connect.json` and its `.bak`) sitting side by
5401        // side, which reads as confusing/ambiguous mid-incident.
5402        assert!(
5403            !connect_json_path(&temp).exists(),
5404            "quarantine must MOVE the corrupt connect.json (not copy it) — no \
5405             connect.json should remain after quarantine"
5406        );
5407    }
5408
5409    #[test]
5410    fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
5411        let temp = TempHome::new();
5412        // A legacy inline `connect` key is present too — it must NOT be used as a
5413        // fallback when connect.json is corrupt (security-sensitive: fail safe).
5414        temp.set_config_json(
5415            &serde_json::json!({
5416                "connect": {
5417                    "platforms": [
5418                        { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
5419                    ]
5420                }
5421            })
5422            .to_string(),
5423        );
5424        std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
5425
5426        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5427        assert!(
5428            config.connect.platforms.is_empty(),
5429            "corrupt connect.json must not fall back to the legacy config.json copy"
5430        );
5431    }
5432
5433    #[test]
5434    fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
5435        let temp = TempHome::new();
5436        let config = Config::create_default();
5437        assert!(config.connect.platforms.is_empty());
5438
5439        config
5440            .save_to_dir(temp.path.clone())
5441            .expect("save succeeds");
5442
5443        assert!(
5444            !connect_json_path(&temp).exists(),
5445            "an empty connect config with no pre-existing file must not create one"
5446        );
5447    }
5448
5449    #[test]
5450    fn connect_json_backed_up_before_overwrite() {
5451        let temp = TempHome::new();
5452        std::fs::write(
5453            connect_json_path(&temp),
5454            serde_json::json!({
5455                "platforms": [
5456                    { "type": "telegram", "token_encrypted": "old-cipher" }
5457                ]
5458            })
5459            .to_string(),
5460        )
5461        .unwrap();
5462
5463        let mut config = Config::create_default();
5464        config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
5465        config
5466            .save_to_dir(temp.path.clone())
5467            .expect("save succeeds");
5468
5469        let backup = connect_json_path(&temp).with_extension("json.bak");
5470        assert!(
5471            std::fs::read_to_string(backup)
5472                .unwrap()
5473                .contains("old-cipher"),
5474            "the previous connect.json is preserved as connect.json.bak before the overwrite"
5475        );
5476        let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
5477        assert!(current.contains("new-cipher"));
5478    }
5479
5480    // ── Feishu adapter config fields (epic #447 phase 3, §2a) ───────────
5481
5482    #[test]
5483    fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
5484        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
5485        let temp = TempHome::new();
5486
5487        let mut config = Config::create_default();
5488        config.connect.platforms = vec![ConnectPlatformConfig {
5489            id: None,
5490            platform_type: "feishu".to_string(),
5491            token: None,
5492            token_encrypted: None,
5493            app_id: Some("cli_real_app_id".to_string()),
5494            app_secret: Some("plain-app-secret".to_string()),
5495            app_secret_encrypted: None,
5496            domain: Some("lark".to_string()),
5497            allow_from: vec!["ou_1".to_string()],
5498            admin_from: Vec::new(),
5499        }];
5500
5501        config
5502            .save_to_dir(temp.path.clone())
5503            .expect("save succeeds");
5504
5505        let connect_json: serde_json::Value =
5506            serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
5507                .unwrap();
5508        assert_eq!(connect_json["platforms"][0]["type"], "feishu");
5509        assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
5510        assert_eq!(connect_json["platforms"][0]["domain"], "lark");
5511        assert!(
5512            connect_json["platforms"][0]["app_secret_encrypted"]
5513                .as_str()
5514                .is_some_and(|v| !v.is_empty()),
5515            "app_secret is persisted in its encrypted form in connect.json"
5516        );
5517        assert!(
5518            connect_json["platforms"][0].get("app_secret").is_none(),
5519            "the plaintext app_secret is never persisted (skip_serializing)"
5520        );
5521    }
5522
5523    #[test]
5524    fn load_hydrates_feishu_app_secret_from_encrypted() {
5525        let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
5526        let temp = TempHome::new();
5527
5528        let mut config = Config::create_default();
5529        config.connect.platforms = vec![ConnectPlatformConfig {
5530            id: None,
5531            platform_type: "feishu".to_string(),
5532            token: None,
5533            token_encrypted: None,
5534            app_id: Some("cli_real_app_id".to_string()),
5535            app_secret: Some("plain-app-secret".to_string()),
5536            app_secret_encrypted: None,
5537            domain: Some("lark".to_string()),
5538            allow_from: vec!["ou_1".to_string()],
5539            admin_from: Vec::new(),
5540        }];
5541        config
5542            .save_to_dir(temp.path.clone())
5543            .expect("save succeeds");
5544
5545        let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5546        assert_eq!(reloaded.connect.platforms.len(), 1);
5547        assert_eq!(
5548            reloaded.connect.platforms[0].app_secret.as_deref(),
5549            Some("plain-app-secret"),
5550            "reload hydrates app_secret from app_secret_encrypted"
5551        );
5552        assert_eq!(
5553            reloaded.connect.platforms[0].app_id.as_deref(),
5554            Some("cli_real_app_id")
5555        );
5556        assert_eq!(
5557            reloaded.connect.platforms[0].domain.as_deref(),
5558            Some("lark")
5559        );
5560    }
5561
5562    #[test]
5563    fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
5564        let temp = TempHome::new();
5565        std::fs::write(
5566            connect_json_path(&temp),
5567            serde_json::json!({
5568                "platforms": [
5569                    { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
5570                ]
5571            })
5572            .to_string(),
5573        )
5574        .unwrap();
5575
5576        let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
5577
5578        assert_eq!(config.connect.platforms.len(), 1);
5579        assert_eq!(config.connect.platforms[0].platform_type, "telegram");
5580        assert_eq!(
5581            config.connect.platforms[0].token_encrypted.as_deref(),
5582            Some("legacy-cipher")
5583        );
5584        assert_eq!(
5585            config.connect.platforms[0].app_id, None,
5586            "a legacy entry with no Feishu fields deserializes them as None"
5587        );
5588        assert_eq!(config.connect.platforms[0].app_secret, None);
5589        assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
5590        assert_eq!(config.connect.platforms[0].domain, None);
5591    }
5592
5593    #[test]
5594    fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
5595        let _lock = env_lock_acquire();
5596        let temp_home = TempHome::new();
5597        temp_home.set_config_json(
5598            r#"{
5599  "provider": "openai",
5600  "providers": {
5601    "openai": {
5602      "api_key": "sk-test",
5603      "model": "gpt-4o"
5604    }
5605  }
5606}"#,
5607        );
5608
5609        let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
5610        let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
5611
5612        let config = Config::from_data_dir(Some(temp_home.path.clone()));
5613
5614        assert_eq!(
5615            config
5616                .providers
5617                .openai
5618                .as_ref()
5619                .and_then(|c| c.model.as_deref()),
5620            Some("gpt-4o")
5621        );
5622        assert!(
5623            config.http_proxy.is_empty(),
5624            "config should keep http_proxy empty when field is omitted"
5625        );
5626        assert!(
5627            config.https_proxy.is_empty(),
5628            "config should keep https_proxy empty when field is omitted"
5629        );
5630    }
5631
5632    #[test]
5633    fn get_memory_background_model_prefers_memory_specific_override() {
5634        let mut config = Config::default();
5635        config.features.provider_model_ref = false;
5636        config.provider = "openai".to_string();
5637        config.providers.openai = Some(OpenAIConfig {
5638            api_key: "test".to_string(),
5639            api_key_encrypted: None,
5640            base_url: None,
5641            model: Some("gpt-main".to_string()),
5642            fast_model: Some("gpt-fast".to_string()),
5643            vision_model: None,
5644            reasoning_effort: None,
5645            responses_only_models: vec![],
5646            request_overrides: None,
5647            extra: BTreeMap::new(),
5648            api_key_from_env: false,
5649        });
5650        config.memory = Some(MemoryConfig {
5651            background_model: Some("memory-fast".to_string()),
5652            ..MemoryConfig::default()
5653        });
5654
5655        assert_eq!(
5656            config.get_memory_background_model().as_deref(),
5657            Some("memory-fast")
5658        );
5659    }
5660
5661    #[test]
5662    fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
5663        // #373: the settings-PATCH serde round-trip drops every provider's
5664        // skip_serializing api_key; an env-sourced key (no ciphertext) can't be
5665        // re-hydrated, so it must be copied back from the live `current` config —
5666        // but an explicitly re-set key and non-env keys must NOT be touched.
5667        let openai = |api_key: &str, from_env: bool| OpenAIConfig {
5668            api_key: api_key.to_string(),
5669            api_key_encrypted: None,
5670            base_url: None,
5671            model: None,
5672            fast_model: None,
5673            vision_model: None,
5674            reasoning_effort: None,
5675            responses_only_models: vec![],
5676            request_overrides: None,
5677            extra: BTreeMap::new(),
5678            api_key_from_env: from_env,
5679        };
5680
5681        // Env-sourced key dropped by the round-trip → restored.
5682        let mut current = Config::default();
5683        current.providers.openai = Some(openai("sk-env", true));
5684        let mut merged = Config::default();
5685        merged.providers.openai = Some(openai("", false)); // post-round-trip
5686        merged.preserve_env_sourced_provider_keys(&current);
5687        let got = merged.providers.openai.as_ref().unwrap();
5688        assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
5689        assert!(got.api_key_from_env, "env flag restored");
5690
5691        // A key explicitly re-set by the patch is NOT overridden.
5692        let mut merged = Config::default();
5693        merged.providers.openai = Some(openai("sk-explicit", false));
5694        merged.preserve_env_sourced_provider_keys(&current);
5695        assert_eq!(
5696            merged.providers.openai.as_ref().unwrap().api_key,
5697            "sk-explicit",
5698            "explicit patch key must win"
5699        );
5700
5701        // A non-env key in current is NOT restored here (that's ciphertext hydration's job).
5702        let mut current_plain = Config::default();
5703        current_plain.providers.openai = Some(openai("sk-plain", false));
5704        let mut merged = Config::default();
5705        merged.providers.openai = Some(openai("", false));
5706        merged.preserve_env_sourced_provider_keys(&current_plain);
5707        assert!(
5708            merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
5709            "non-env key must not be restored by this path"
5710        );
5711    }
5712
5713    #[test]
5714    fn refresh_preserves_ciphertext_when_plaintext_empty() {
5715        // #268: a provider whose stored ciphertext failed to decrypt at hydration
5716        // has an empty in-memory api_key. An unrelated later save must NOT null its
5717        // ciphertext — that would permanently drop a key the user never touched.
5718        let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
5719            api_key: api_key.to_string(),
5720            api_key_encrypted: enc.map(str::to_string),
5721            base_url: None,
5722            model: None,
5723            fast_model: None,
5724            vision_model: None,
5725            reasoning_effort: None,
5726            responses_only_models: vec![],
5727            request_overrides: None,
5728            extra: BTreeMap::new(),
5729            api_key_from_env: false,
5730        };
5731
5732        // Empty plaintext + existing ciphertext → ciphertext preserved (the bug).
5733        let mut config = Config::default();
5734        config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
5735        config
5736            .refresh_provider_api_keys_encrypted()
5737            .expect("refresh");
5738        assert_eq!(
5739            config
5740                .providers
5741                .openai
5742                .as_ref()
5743                .unwrap()
5744                .api_key_encrypted
5745                .as_deref(),
5746            Some("preexisting-ciphertext"),
5747            "existing ciphertext must be preserved when plaintext is empty"
5748        );
5749
5750        // Empty plaintext + no ciphertext → stays None (nothing to preserve).
5751        let mut config = Config::default();
5752        config.providers.openai = Some(openai("", None));
5753        config
5754            .refresh_provider_api_keys_encrypted()
5755            .expect("refresh");
5756        assert!(
5757            config
5758                .providers
5759                .openai
5760                .as_ref()
5761                .unwrap()
5762                .api_key_encrypted
5763                .is_none(),
5764            "no key + no ciphertext should stay None"
5765        );
5766
5767        // Non-empty plaintext → (re)encrypted to a fresh, non-empty ciphertext.
5768        let mut config = Config::default();
5769        config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
5770        config
5771            .refresh_provider_api_keys_encrypted()
5772            .expect("refresh");
5773        let enc = config
5774            .providers
5775            .openai
5776            .as_ref()
5777            .unwrap()
5778            .api_key_encrypted
5779            .clone()
5780            .expect("ciphertext present");
5781        assert!(
5782            !enc.is_empty() && enc != "stale-ciphertext",
5783            "plaintext re-encrypted"
5784        );
5785    }
5786
5787    #[test]
5788    fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
5789        // #516: `save_to_dir` refreshes ciphertext only on its save-time clone,
5790        // so a provider instance created over HTTP stays plaintext-only in the
5791        // live config. Serializing that live config (as the settings-PATCH
5792        // merge does) drops the `skip_serializing` plaintext and the key is
5793        // gone. `refresh_encrypted_secrets` on the live config closes the gap.
5794        let mut config = Config::default();
5795        let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
5796            "provider_type": "openai",
5797            "api_key": "sk-instance-live",
5798        }))
5799        .expect("valid instance");
5800        config
5801            .provider_instances
5802            .insert("work".to_string(), instance);
5803
5804        config.refresh_encrypted_secrets().expect("refresh");
5805        assert!(
5806            config.provider_instances["work"]
5807                .api_key_encrypted
5808                .is_some(),
5809            "live config must hold ciphertext after refresh"
5810        );
5811
5812        // The build_merged_config-style round-trip.
5813        let value = serde_json::to_value(&config).expect("serialize");
5814        let mut back: Config = serde_json::from_value(value).expect("deserialize");
5815        assert!(
5816            back.provider_instances["work"].api_key.is_empty(),
5817            "plaintext is skip_serializing"
5818        );
5819        back.hydrate_provider_instance_api_keys_from_encrypted();
5820        assert_eq!(
5821            back.provider_instances["work"].api_key, "sk-instance-live",
5822            "key must be recoverable from the round-tripped ciphertext"
5823        );
5824    }
5825
5826    #[test]
5827    fn get_memory_background_model_falls_back_to_provider_fast_model() {
5828        let mut config = Config::default();
5829        config.features.provider_model_ref = false;
5830        config.provider = "openai".to_string();
5831        config.providers.openai = Some(OpenAIConfig {
5832            api_key: "test".to_string(),
5833            api_key_encrypted: None,
5834            base_url: None,
5835            model: Some("gpt-main".to_string()),
5836            fast_model: Some("gpt-fast".to_string()),
5837            vision_model: None,
5838            reasoning_effort: None,
5839            responses_only_models: vec![],
5840            request_overrides: None,
5841            extra: BTreeMap::new(),
5842            api_key_from_env: false,
5843        });
5844
5845        assert_eq!(
5846            config.get_memory_background_model().as_deref(),
5847            Some("gpt-fast")
5848        );
5849    }
5850
5851    #[test]
5852    fn get_memory_background_model_does_not_fall_back_to_main_model() {
5853        let mut config = Config::default();
5854        config.features.provider_model_ref = false;
5855        config.provider = "openai".to_string();
5856        config.providers.openai = Some(OpenAIConfig {
5857            api_key: "test".to_string(),
5858            api_key_encrypted: None,
5859            base_url: None,
5860            model: Some("gpt-main".to_string()),
5861            fast_model: None,
5862            vision_model: None,
5863            reasoning_effort: None,
5864            responses_only_models: vec![],
5865            request_overrides: None,
5866            extra: BTreeMap::new(),
5867            api_key_from_env: false,
5868        });
5869
5870        assert!(config.get_memory_background_model().is_none());
5871    }
5872
5873    #[test]
5874    fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
5875        let config = Config {
5876            memory: Some(MemoryConfig {
5877                background_model: Some("dream-fast".to_string()),
5878                auto_dream_enabled: true,
5879                auto_dream_interval_secs: 900,
5880                project_prompt_injection: false,
5881                relevant_recall: false,
5882                relevant_recall_rerank: true,
5883                project_first_dream: false,
5884                ledger_agenda_injection: false,
5885                ledger_gardener_enabled: false,
5886                ledger_gardener_interval_secs: 7_200,
5887                ledger_distillation_enabled: false,
5888                dream_refine_mode: true,
5889                gardener_enabled: true,
5890                gardener_interval_secs: 3_600,
5891                gardener_volume_trigger: 40,
5892                gardener_max_splits_per_run: 4,
5893                gardener_min_sections: 7,
5894                dedup_gardener_enabled: true,
5895                dedup_gardener_min_score: 0.7,
5896                dedup_gardener_max_merges_per_run: 3,
5897                memory_active_capacity: 500,
5898                capacity_max_archivals_per_run: 10,
5899                granularity_freshness_gardener_enabled: false,
5900            }),
5901            ..Config::default()
5902        };
5903
5904        let serialized = serde_json::to_string(&config).expect("config should serialize");
5905        let roundtrip: Config =
5906            serde_json::from_str(&serialized).expect("config should deserialize");
5907        let memory = roundtrip.memory.expect("memory config should exist");
5908        assert!(memory.auto_dream_enabled);
5909        assert!(!memory.project_prompt_injection);
5910        assert!(!memory.relevant_recall);
5911        assert!(memory.relevant_recall_rerank);
5912        assert!(!memory.project_first_dream);
5913        assert!(memory.dream_refine_mode);
5914        assert!(memory.gardener_enabled);
5915        assert_eq!(memory.gardener_interval_secs, 3_600);
5916        assert_eq!(memory.gardener_volume_trigger, 40);
5917        assert_eq!(memory.gardener_max_splits_per_run, 4);
5918        assert_eq!(memory.gardener_min_sections, 7);
5919        assert!(memory.dedup_gardener_enabled);
5920        assert_eq!(memory.dedup_gardener_min_score, 0.7);
5921        assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
5922        assert_eq!(memory.memory_active_capacity, 500);
5923        assert_eq!(memory.capacity_max_archivals_per_run, 10);
5924        assert!(!memory.granularity_freshness_gardener_enabled);
5925    }
5926
5927    /// L5: capacity is OFF by default (0 = unbounded) — an opt-in feature.
5928    #[test]
5929    fn memory_active_capacity_defaults_off() {
5930        assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
5931        assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
5932        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
5933        let memory = parsed.memory.unwrap();
5934        assert_eq!(memory.memory_active_capacity, 0);
5935        assert_eq!(
5936            memory.capacity_max_archivals_per_run, 50,
5937            "omitted field takes the serde default fn"
5938        );
5939    }
5940
5941    /// L4: the maintenance integrators are ON by default — both via
5942    /// `MemoryConfig::default()` AND when a config file omits the flags entirely
5943    /// (serde `default = fn`, not the bare `#[serde(default)]` = `false`).
5944    #[test]
5945    fn memory_maintenance_integrators_default_on() {
5946        let defaults = MemoryConfig::default();
5947        assert!(defaults.auto_dream_enabled);
5948        assert!(defaults.gardener_enabled);
5949        assert!(defaults.dedup_gardener_enabled);
5950        assert_eq!(defaults.gardener_volume_trigger, 25);
5951
5952        // A config that mentions `memory` but omits the flags must still be ON.
5953        let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
5954        let memory = parsed.memory.expect("memory present");
5955        assert!(
5956            memory.auto_dream_enabled,
5957            "auto_dream on when field omitted"
5958        );
5959        assert!(memory.gardener_enabled, "gardener on when field omitted");
5960        assert!(
5961            memory.dedup_gardener_enabled,
5962            "dedup gardener on when field omitted"
5963        );
5964        // An explicit opt-out is still honored.
5965        let opted_out: Config =
5966            serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
5967        assert!(!opted_out.memory.unwrap().gardener_enabled);
5968    }
5969
5970    #[test]
5971    fn memory_config_env_overrides_prompt_flags() {
5972        let _lock = env_lock_acquire();
5973        let temp_home = TempHome::new();
5974        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
5975        let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
5976        let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
5977        let _relevant_recall_rerank =
5978            EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
5979        let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
5980
5981        let config = Config::from_data_dir(Some(temp_home.path.clone()));
5982        let memory = config
5983            .memory
5984            .expect("memory config should be created by env overrides");
5985        assert!(!memory.project_prompt_injection);
5986        assert!(!memory.relevant_recall);
5987        assert!(memory.relevant_recall_rerank);
5988        assert!(!memory.project_first_dream);
5989    }
5990
5991    #[test]
5992    fn provider_api_keys_injected_from_env_and_never_persisted() {
5993        let _lock = env_lock_acquire();
5994        let temp_home = TempHome::new();
5995        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
5996        let _anthropic = EnvVarGuard::set("BAMBOO_ANTHROPIC_API_KEY", "sk-ant-from-env");
5997        let _openai = EnvVarGuard::set("BAMBOO_OPENAI_API_KEY", "sk-oai-from-env");
5998
5999        // No config.json on disk → the providers are created from the env keys
6000        // alone (#253: deploy without a plaintext api_key in a mounted file).
6001        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6002        assert_eq!(
6003            config
6004                .providers
6005                .anthropic
6006                .as_ref()
6007                .expect("anthropic created from env")
6008                .api_key,
6009            "sk-ant-from-env"
6010        );
6011        assert_eq!(
6012            config
6013                .providers
6014                .openai
6015                .as_ref()
6016                .expect("openai created from env")
6017                .api_key,
6018            "sk-oai-from-env"
6019        );
6020        // An unset provider is not fabricated.
6021        assert!(config.providers.gemini.is_none());
6022
6023        // The real "never persisted" guarantee: saving the config must NOT bake
6024        // the env key into config.json — not as plaintext AND not re-encrypted
6025        // into `api_key_encrypted` (which save's `refresh_provider_api_keys_encrypted`
6026        // would otherwise do). This is what actually happens on the server when
6027        // any unrelated setting is saved / on a fabric-reconcile boot.
6028        config
6029            .save_to_dir(temp_home.path.clone())
6030            .expect("save config");
6031        let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
6032            .expect("read persisted config.json");
6033        assert!(
6034            !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
6035            "env key must not be persisted as plaintext"
6036        );
6037        let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
6038        assert!(
6039            disk_json["providers"]["anthropic"]
6040                .get("api_key_encrypted")
6041                .is_none(),
6042            "env-sourced anthropic key must not be re-encrypted into config.json"
6043        );
6044        assert!(
6045            disk_json["providers"]["openai"]
6046                .get("api_key_encrypted")
6047                .is_none(),
6048            "env-sourced openai key must not be re-encrypted into config.json"
6049        );
6050
6051        // And once the env vars are gone, a reload from that same dir has no key
6052        // (nothing was persisted).
6053        drop(_anthropic);
6054        drop(_openai);
6055        let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
6056        assert!(reloaded
6057            .providers
6058            .anthropic
6059            .as_ref()
6060            .map(|a| a.api_key.is_empty())
6061            .unwrap_or(true));
6062    }
6063
6064    #[test]
6065    fn get_default_work_area_path_expands_tilde_and_requires_directory() {
6066        let _lock = env_lock_acquire();
6067        let temp_home = TempHome::new();
6068        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
6069        let target = temp_home.path.join("workspace-default");
6070        std::fs::create_dir_all(&target).expect("default work area dir should exist");
6071
6072        let config = Config {
6073            default_work_area: Some(DefaultWorkAreaConfig {
6074                path: Some("~/workspace-default".to_string()),
6075            }),
6076            ..Default::default()
6077        };
6078
6079        assert_eq!(config.get_default_work_area_path(), Some(target));
6080    }
6081
6082    #[test]
6083    fn get_default_work_area_path_returns_none_for_missing_directory() {
6084        let _lock = env_lock_acquire();
6085        let temp_home = TempHome::new();
6086        let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
6087
6088        let config = Config {
6089            default_work_area: Some(DefaultWorkAreaConfig {
6090                path: Some("~/missing-default-work-area".to_string()),
6091            }),
6092            ..Default::default()
6093        };
6094
6095        assert!(config.get_default_work_area_path().is_none());
6096    }
6097
6098    #[test]
6099    fn normalize_tool_settings_trims_dedupes_canonicalizes_and_sorts() {
6100        let mut config = Config::default();
6101        config.tools.disabled = vec![
6102            "  read_file  ".to_string(),
6103            "".to_string(),
6104            "read_file".to_string(),
6105            "bash".to_string(),
6106            "default::getCurrentDir".to_string(),
6107        ];
6108
6109        config.normalize_tool_settings();
6110
6111        assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
6112    }
6113
6114    #[test]
6115    fn config_load_reads_disabled_tools_as_canonical_names() {
6116        let _lock = env_lock_acquire();
6117        let temp_home = TempHome::new();
6118        temp_home.set_config_json(
6119            r#"{
6120  "tools": {
6121    "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
6122  }
6123}"#,
6124        );
6125
6126        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6127        assert_eq!(config.tools.disabled, vec!["Bash", "GetCurrentDir", "Read"]);
6128        assert!(config.disabled_tool_names().contains("Bash"));
6129        assert!(config.disabled_tool_names().contains("Read"));
6130        assert!(config.disabled_tool_names().contains("GetCurrentDir"));
6131    }
6132
6133    #[test]
6134    fn normalize_skill_settings_trims_dedupes_and_sorts() {
6135        let mut config = Config::default();
6136        config.skills.disabled = vec![
6137            " pdf ".to_string(),
6138            "".to_string(),
6139            "pdf".to_string(),
6140            "skill-creator".to_string(),
6141        ];
6142
6143        config.normalize_skill_settings();
6144
6145        assert_eq!(
6146            config.skills.disabled,
6147            vec!["pdf".to_string(), "skill-creator".to_string()]
6148        );
6149    }
6150
6151    #[test]
6152    fn config_load_reads_disabled_skills_as_normalized_ids() {
6153        let _lock = env_lock_acquire();
6154        let temp_home = TempHome::new();
6155        temp_home.set_config_json(
6156            r#"{
6157  "skills": {
6158    "disabled": [" pdf ", "skill-creator", "pdf", ""]
6159  }
6160}"#,
6161        );
6162
6163        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6164        assert_eq!(
6165            config.skills.disabled,
6166            vec!["pdf".to_string(), "skill-creator".to_string()]
6167        );
6168        assert!(config.disabled_skill_ids().contains("pdf"));
6169        assert!(config.disabled_skill_ids().contains("skill-creator"));
6170    }
6171
6172    #[test]
6173    fn test_server_config_defaults() {
6174        let _lock = env_lock_acquire();
6175        let temp_home = TempHome::new();
6176
6177        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6178        assert_eq!(config.server.port, 9562);
6179        assert_eq!(config.server.bind, "127.0.0.1");
6180        assert_eq!(config.server.workers, 10);
6181        assert!(config.server.static_dir.is_none());
6182    }
6183
6184    #[test]
6185    fn test_server_addr() {
6186        let mut config = Config::default();
6187        config.server.port = 9000;
6188        config.server.bind = "0.0.0.0".to_string();
6189        assert_eq!(config.server_addr(), "0.0.0.0:9000");
6190    }
6191
6192    #[test]
6193    fn test_env_var_overrides() {
6194        let _lock = env_lock_acquire();
6195        let temp_home = TempHome::new();
6196
6197        let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
6198        let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
6199        let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
6200
6201        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6202        assert_eq!(config.server.port, 9999);
6203        assert_eq!(config.server.bind, "192.168.1.1");
6204        assert_eq!(config.provider, "openai");
6205    }
6206
6207    #[test]
6208    fn test_config_save_and_load() {
6209        let _lock = env_lock_acquire();
6210        let temp_home = TempHome::new();
6211
6212        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
6213        config.server.port = 9000;
6214        config.server.bind = "0.0.0.0".to_string();
6215        config.provider = "anthropic".to_string();
6216
6217        // Save
6218        config
6219            .save_to_dir(temp_home.path.clone())
6220            .expect("Failed to save config");
6221
6222        // Load again
6223        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
6224
6225        // Verify
6226        assert_eq!(loaded.server.port, 9000);
6227        assert_eq!(loaded.server.bind, "0.0.0.0");
6228        assert_eq!(loaded.provider, "anthropic");
6229    }
6230
6231    #[test]
6232    fn config_decrypts_proxy_auth_from_encrypted_field() {
6233        let _lock = env_lock_acquire();
6234        let temp_home = TempHome::new();
6235
6236        // Use a stable encryption key so this test doesn't depend on host identifiers.
6237        let key_guard = crate::encryption::set_test_encryption_key([
6238            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
6239            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
6240            0x1c, 0x1d, 0x1e, 0x1f,
6241        ]);
6242
6243        let auth = ProxyAuth {
6244            username: "user".to_string(),
6245            password: "pass".to_string(),
6246        };
6247        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
6248        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
6249
6250        temp_home.set_config_json(&format!(
6251            r#"{{
6252  "http_proxy": "http://proxy.example.com:8080",
6253  "proxy_auth_encrypted": "{encrypted}"
6254}}"#
6255        ));
6256        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6257        let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
6258        assert_eq!(loaded_auth.username, "user");
6259        assert_eq!(loaded_auth.password, "pass");
6260        drop(key_guard);
6261    }
6262
6263    #[test]
6264    fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
6265        let _lock = env_lock_acquire();
6266        let temp_home = TempHome::new();
6267
6268        // Use a stable encryption key so this test doesn't depend on host identifiers.
6269        let key_guard = crate::encryption::set_test_encryption_key([
6270            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
6271            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
6272            0x1c, 0x1d, 0x1e, 0x1f,
6273        ]);
6274
6275        let auth = ProxyAuth {
6276            username: "user".to_string(),
6277            password: "pass".to_string(),
6278        };
6279        let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
6280        let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
6281
6282        // Simulate older Bodhi/Tauri persisted config keys.
6283        temp_home.set_config_json(&format!(
6284            r#"{{
6285  "http_proxy": "http://proxy.example.com:8080",
6286  "http_proxy_auth_encrypted": "{encrypted}",
6287  "https_proxy_auth_encrypted": "{encrypted}"
6288}}"#
6289        ));
6290
6291        let config = Config::from_data_dir(Some(temp_home.path.clone()));
6292        let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
6293        assert_eq!(loaded_auth.username, "user");
6294        assert_eq!(loaded_auth.password, "pass");
6295        drop(key_guard);
6296    }
6297
6298    #[test]
6299    fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
6300        let _lock = env_lock_acquire();
6301        let temp_home = TempHome::new();
6302
6303        // Use a stable encryption key so this test doesn't depend on host identifiers.
6304        let key_guard = crate::encryption::set_test_encryption_key([
6305            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
6306            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
6307            0x1c, 0x1d, 0x1e, 0x1f,
6308        ]);
6309
6310        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
6311        config.proxy_auth = Some(ProxyAuth {
6312            username: "user".to_string(),
6313            password: "pass".to_string(),
6314        });
6315        config
6316            .save_to_dir(temp_home.path.clone())
6317            .expect("save should encrypt proxy auth");
6318
6319        let content =
6320            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
6321        assert!(
6322            content.contains("proxy_auth_encrypted"),
6323            "config.json should store encrypted proxy auth"
6324        );
6325        assert!(
6326            !content.contains("\"proxy_auth\""),
6327            "config.json should not store plaintext proxy_auth"
6328        );
6329
6330        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
6331        let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
6332        assert_eq!(loaded_auth.username, "user");
6333        assert_eq!(loaded_auth.password, "pass");
6334        drop(key_guard);
6335    }
6336
6337    #[test]
6338    fn config_save_encrypts_provider_api_keys_and_does_not_persist_plaintext() {
6339        let _lock = env_lock_acquire();
6340        let temp_home = TempHome::new();
6341
6342        // Use a stable encryption key so this test doesn't depend on host identifiers.
6343        let key_guard = crate::encryption::set_test_encryption_key([
6344            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
6345            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
6346            0x1c, 0x1d, 0x1e, 0x1f,
6347        ]);
6348
6349        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
6350        config.provider = "openai".to_string();
6351        config.providers.openai = Some(OpenAIConfig {
6352            api_key: "sk-test-provider-key".to_string(),
6353            api_key_encrypted: None,
6354            base_url: None,
6355            model: None,
6356            fast_model: None,
6357            vision_model: None,
6358            reasoning_effort: None,
6359            responses_only_models: vec![],
6360            request_overrides: None,
6361            extra: Default::default(),
6362            api_key_from_env: false,
6363        });
6364
6365        config
6366            .save_to_dir(temp_home.path.clone())
6367            .expect("save should encrypt provider api keys");
6368
6369        let content =
6370            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
6371        assert!(
6372            content.contains("\"api_key_encrypted\""),
6373            "config.json should store encrypted provider keys"
6374        );
6375        assert!(
6376            !content.contains("\"api_key\""),
6377            "config.json should not store plaintext provider keys"
6378        );
6379
6380        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
6381        let openai = loaded
6382            .providers
6383            .openai
6384            .expect("openai config should be present");
6385        assert_eq!(openai.api_key, "sk-test-provider-key");
6386
6387        drop(key_guard);
6388    }
6389
6390    #[test]
6391    fn config_save_persists_mcp_servers_in_mainstream_format() {
6392        let _lock = env_lock_acquire();
6393        let temp_home = TempHome::new();
6394
6395        let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
6396
6397        let mut env = std::collections::HashMap::new();
6398        env.insert("TOKEN".to_string(), "supersecret".to_string());
6399
6400        config.mcp.servers = vec![
6401            bamboo_domain::mcp_config::McpServerConfig {
6402                id: "stdio-secret".to_string(),
6403                name: None,
6404                enabled: true,
6405                transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
6406                    bamboo_domain::mcp_config::StdioConfig {
6407                        command: "echo".to_string(),
6408                        args: vec![],
6409                        cwd: None,
6410                        env,
6411                        env_encrypted: std::collections::HashMap::new(),
6412                        startup_timeout_ms: 5000,
6413                    },
6414                ),
6415                request_timeout_ms: 5000,
6416                healthcheck_interval_ms: 1000,
6417                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
6418                allowed_tools: vec![],
6419                denied_tools: vec![],
6420            },
6421            bamboo_domain::mcp_config::McpServerConfig {
6422                id: "sse-secret".to_string(),
6423                name: None,
6424                enabled: true,
6425                transport: bamboo_domain::mcp_config::TransportConfig::Sse(
6426                    bamboo_domain::mcp_config::SseConfig {
6427                        url: "http://localhost:8080/sse".to_string(),
6428                        headers: vec![bamboo_domain::mcp_config::HeaderConfig {
6429                            name: "Authorization".to_string(),
6430                            value: "Bearer token123".to_string(),
6431                            value_encrypted: None,
6432                        }],
6433                        connect_timeout_ms: 5000,
6434                    },
6435                ),
6436                request_timeout_ms: 5000,
6437                healthcheck_interval_ms: 1000,
6438                reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
6439                allowed_tools: vec![],
6440                denied_tools: vec![],
6441            },
6442        ];
6443
6444        config
6445            .save_to_dir(temp_home.path.clone())
6446            .expect("save should persist MCP servers");
6447
6448        let content =
6449            std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
6450        assert!(
6451            content.contains("\"mcpServers\""),
6452            "config.json should store MCP servers under the mainstream 'mcpServers' key"
6453        );
6454        assert!(
6455            content.contains("supersecret"),
6456            "config.json should persist MCP stdio env in mainstream format"
6457        );
6458        assert!(
6459            content.contains("Bearer token123"),
6460            "config.json should persist MCP SSE headers in mainstream format"
6461        );
6462        assert!(
6463            !content.contains("\"env_encrypted\""),
6464            "config.json should not persist legacy env_encrypted fields"
6465        );
6466        assert!(
6467            !content.contains("\"value_encrypted\""),
6468            "config.json should not persist legacy value_encrypted fields"
6469        );
6470
6471        let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
6472        let stdio = loaded
6473            .mcp
6474            .servers
6475            .iter()
6476            .find(|s| s.id == "stdio-secret")
6477            .expect("stdio server should exist");
6478        match &stdio.transport {
6479            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
6480                assert_eq!(
6481                    stdio.env.get("TOKEN").map(|s| s.as_str()),
6482                    Some("supersecret")
6483                );
6484            }
6485            _ => panic!("Expected stdio transport"),
6486        }
6487
6488        let sse = loaded
6489            .mcp
6490            .servers
6491            .iter()
6492            .find(|s| s.id == "sse-secret")
6493            .expect("sse server should exist");
6494        match &sse.transport {
6495            bamboo_domain::mcp_config::TransportConfig::Sse(sse) => {
6496                assert_eq!(sse.headers[0].value, "Bearer token123");
6497            }
6498            _ => panic!("Expected SSE transport"),
6499        }
6500    }
6501
6502    // ── Env vars lifecycle tests ──────────────────────────────
6503
6504    #[test]
6505    fn env_vars_as_map_includes_only_non_empty_values() {
6506        let config = Config {
6507            env_vars: vec![
6508                EnvVarEntry {
6509                    name: "A".to_string(),
6510                    value: "val_a".to_string(),
6511                    secret: false,
6512                    value_encrypted: None,
6513                    description: None,
6514                },
6515                EnvVarEntry {
6516                    name: "B".to_string(),
6517                    value: "".to_string(), // empty → should be excluded
6518                    secret: true,
6519                    value_encrypted: None,
6520                    description: None,
6521                },
6522                EnvVarEntry {
6523                    name: "C".to_string(),
6524                    value: "  ".to_string(), // whitespace-only → excluded
6525                    secret: false,
6526                    value_encrypted: None,
6527                    description: None,
6528                },
6529                EnvVarEntry {
6530                    name: "D".to_string(),
6531                    value: "val_d".to_string(),
6532                    secret: true,
6533                    value_encrypted: Some("enc".to_string()),
6534                    description: Some("desc".to_string()),
6535                },
6536            ],
6537            ..Default::default()
6538        };
6539
6540        let map = config.env_vars_as_map();
6541        assert_eq!(map.len(), 2);
6542        assert_eq!(map.get("A"), Some(&"val_a".to_string()));
6543        assert_eq!(map.get("D"), Some(&"val_d".to_string()));
6544        assert!(!map.contains_key("B"));
6545        assert!(!map.contains_key("C"));
6546    }
6547
6548    #[test]
6549    fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
6550        let mut config = Config {
6551            env_vars: vec![
6552                EnvVarEntry {
6553                    name: "PLAIN".to_string(),
6554                    value: "visible".to_string(),
6555                    secret: false,
6556                    value_encrypted: None,
6557                    description: None,
6558                },
6559                EnvVarEntry {
6560                    name: "SECRET".to_string(),
6561                    value: "hidden_value".to_string(),
6562                    secret: true,
6563                    value_encrypted: Some("enc_data".to_string()),
6564                    description: None,
6565                },
6566            ],
6567            ..Default::default()
6568        };
6569
6570        config.sanitize_env_vars_for_disk();
6571
6572        assert_eq!(config.env_vars[0].value, "visible"); // plain kept
6573        assert_eq!(config.env_vars[1].value, ""); // secret cleared
6574    }
6575
6576    #[test]
6577    fn sanitize_env_vars_for_disk_preserves_encrypted() {
6578        let mut config = Config {
6579            env_vars: vec![
6580                EnvVarEntry {
6581                    name: "OPEN".to_string(),
6582                    value: "val".to_string(),
6583                    secret: false,
6584                    value_encrypted: None,
6585                    description: None,
6586                },
6587                EnvVarEntry {
6588                    name: "HIDDEN".to_string(),
6589                    value: "real_secret".to_string(),
6590                    secret: true,
6591                    value_encrypted: Some("enc".to_string()),
6592                    description: None,
6593                },
6594            ],
6595            ..Default::default()
6596        };
6597
6598        config.sanitize_env_vars_for_disk();
6599
6600        // Plain value untouched
6601        assert_eq!(config.env_vars[0].value, "val");
6602        // Secret plaintext cleared, but encrypted preserved
6603        assert_eq!(config.env_vars[1].value, "");
6604        assert_eq!(config.env_vars[1].value_encrypted.as_deref(), Some("enc"));
6605    }
6606
6607    #[test]
6608    fn refresh_env_vars_encrypted_round_trip() {
6609        let mut config = Config {
6610            env_vars: vec![
6611                EnvVarEntry {
6612                    name: "TOKEN".to_string(),
6613                    value: "my-secret-token".to_string(),
6614                    secret: true,
6615                    value_encrypted: None,
6616                    description: Some("A token".to_string()),
6617                },
6618                EnvVarEntry {
6619                    name: "PLAIN_VAR".to_string(),
6620                    value: "hello".to_string(),
6621                    secret: false,
6622                    value_encrypted: None,
6623                    description: None,
6624                },
6625            ],
6626            ..Default::default()
6627        };
6628
6629        // Encrypt
6630        config
6631            .refresh_env_vars_encrypted()
6632            .expect("encryption should succeed");
6633
6634        // Secret should now have encrypted value
6635        assert!(config.env_vars[0].value_encrypted.is_some());
6636        // Plain should have no encrypted value
6637        assert!(config.env_vars[1].value_encrypted.is_none());
6638
6639        // Save encrypted value for later comparison
6640        let encrypted = config.env_vars[0].value_encrypted.clone().unwrap();
6641        assert_ne!(encrypted, "my-secret-token"); // shouldn't be plaintext
6642
6643        // Clear plaintext (simulating disk write)
6644        config.sanitize_env_vars_for_disk();
6645        assert_eq!(config.env_vars[0].value, "");
6646
6647        // Hydrate (simulating disk read)
6648        config.hydrate_env_vars_from_encrypted();
6649        assert_eq!(config.env_vars[0].value, "my-secret-token");
6650        assert_eq!(config.env_vars[1].value, "hello"); // plain untouched
6651    }
6652
6653    #[test]
6654    fn publish_and_current_env_vars_round_trip() {
6655        // `publish_env_vars` REPLACES the process-global env-vars cache
6656        // wholesale, so every test that touches that cache must hold the
6657        // crate-wide env lock. This test didn't (issue #486): running
6658        // concurrently with a lock-holding cache test (e.g.
6659        // `from_data_dir_without_publish_does_not_clobber_global_cache`) it
6660        // wiped that test's just-seeded marker out of the cache mid-assert —
6661        // and its own 10x retry loop below was itself a symptom of losing
6662        // the same race in the other direction. With the lock held, one
6663        // publish is deterministic.
6664        let _lock = crate::test_support::env_cache_lock_acquire();
6665        let config = Config {
6666            env_vars: vec![EnvVarEntry {
6667                name: "TEST_PUBLISH".to_string(),
6668                value: "pub_value".to_string(),
6669                secret: false,
6670                value_encrypted: None,
6671                description: None,
6672            }],
6673            ..Default::default()
6674        };
6675
6676        config.publish_env_vars();
6677        assert_eq!(
6678            Config::current_env_vars()
6679                .get("TEST_PUBLISH")
6680                .map(String::as_str),
6681            Some("pub_value")
6682        );
6683    }
6684
6685    #[test]
6686    fn broker_token_round_trips_encrypt_sanitize_hydrate() {
6687        let mut config = Config::default();
6688        config.subagents.broker = Some(BrokerClientConfig {
6689            endpoint: "ws://127.0.0.1:9600".to_string(),
6690            token: "super-secret-token".to_string(),
6691            token_encrypted: None,
6692        });
6693
6694        // Persist path: encrypt then sanitize (what save_to_dir does).
6695        config.refresh_broker_token_encrypted().unwrap();
6696        config.sanitize_broker_token_for_disk();
6697        let broker = config.subagents.broker.as_ref().unwrap();
6698        assert!(broker.token.is_empty(), "plaintext cleared for disk");
6699        assert!(broker.token_encrypted.is_some(), "ciphertext stored");
6700        assert_ne!(
6701            broker.token_encrypted.as_deref(),
6702            Some("super-secret-token")
6703        );
6704
6705        // Load path: hydrate restores plaintext.
6706        config.hydrate_broker_token_from_encrypted();
6707        assert_eq!(
6708            config.subagents.broker.as_ref().unwrap().token,
6709            "super-secret-token"
6710        );
6711    }
6712
6713    #[test]
6714    fn broker_token_empty_refresh_preserves_ciphertext() {
6715        // A redacted round-trip (token empty) must not wipe the stored ciphertext.
6716        let mut config = Config::default();
6717        config.subagents.broker = Some(BrokerClientConfig {
6718            endpoint: "ws://h:9600".to_string(),
6719            token: String::new(),
6720            token_encrypted: Some("existing-cipher".to_string()),
6721        });
6722        config.refresh_broker_token_encrypted().unwrap();
6723        assert_eq!(
6724            config
6725                .subagents
6726                .broker
6727                .as_ref()
6728                .unwrap()
6729                .token_encrypted
6730                .as_deref(),
6731            Some("existing-cipher"),
6732        );
6733    }
6734
6735    #[test]
6736    fn notifications_config_defaults_when_key_missing() {
6737        // Additive/back-compat: an absent `notifications` key must deserialize
6738        // to the built-in defaults (desktop auto, ntfy/bark disabled).
6739        let config: Config = serde_json::from_str("{}").expect("empty object parses");
6740        assert_eq!(config.notifications, NotificationsConfig::default());
6741        assert_eq!(config.notifications.desktop.enabled, None);
6742        assert!(!config.notifications.ntfy.enabled);
6743        assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
6744        assert_eq!(config.notifications.ntfy.token, None);
6745        assert!(!config.notifications.bark.enabled);
6746        assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
6747        assert_eq!(config.notifications.bark.device_key, None);
6748    }
6749
6750    #[test]
6751    fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
6752        let mut config = Config::default();
6753        config.notifications.ntfy = NtfyChannelConfig {
6754            enabled: true,
6755            base_url: "https://ntfy.sh".to_string(),
6756            topic: "bamboo-alerts".to_string(),
6757            token: Some("tk_super_secret".to_string()),
6758            token_encrypted: None,
6759        };
6760
6761        // Persist path: encrypt (what save_to_dir does).
6762        config.refresh_notifications_encrypted().unwrap();
6763        assert!(config.notifications.ntfy.token_encrypted.is_some());
6764        assert_ne!(
6765            config.notifications.ntfy.token_encrypted.as_deref(),
6766            Some("tk_super_secret")
6767        );
6768
6769        // `token` is `#[serde(skip_serializing)]` — never lands on disk, only
6770        // the ciphertext does.
6771        let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
6772        assert!(
6773            !json.contains("tk_super_secret"),
6774            "plaintext token must never be serialized"
6775        );
6776        assert!(json.contains("token_encrypted"));
6777
6778        // Load path: simulate a fresh load (plaintext gone, ciphertext present)
6779        // and confirm hydrate restores the plaintext.
6780        config.notifications.ntfy.token = None;
6781        config.hydrate_notifications_from_encrypted();
6782        assert_eq!(
6783            config.notifications.ntfy.token.as_deref(),
6784            Some("tk_super_secret")
6785        );
6786    }
6787
6788    #[test]
6789    fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
6790        let mut config = Config::default();
6791        config.notifications.bark = BarkChannelConfig {
6792            enabled: true,
6793            base_url: "https://api.day.app".to_string(),
6794            device_key: Some("dk_super_secret".to_string()),
6795            device_key_encrypted: None,
6796        };
6797
6798        config.refresh_notifications_encrypted().unwrap();
6799        assert!(config.notifications.bark.device_key_encrypted.is_some());
6800        assert_ne!(
6801            config.notifications.bark.device_key_encrypted.as_deref(),
6802            Some("dk_super_secret")
6803        );
6804
6805        let json = serde_json::to_string(&config.notifications.bark).unwrap();
6806        assert!(
6807            !json.contains("dk_super_secret"),
6808            "plaintext device key must never be serialized"
6809        );
6810        assert!(json.contains("device_key_encrypted"));
6811
6812        config.notifications.bark.device_key = None;
6813        config.hydrate_notifications_from_encrypted();
6814        assert_eq!(
6815            config.notifications.bark.device_key.as_deref(),
6816            Some("dk_super_secret")
6817        );
6818    }
6819
6820    #[test]
6821    fn notification_secrets_empty_refresh_preserves_ciphertext() {
6822        // A redacted round-trip (plaintext empty/absent) must not wipe the
6823        // stored ciphertext for either channel.
6824        let mut config = Config::default();
6825        config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
6826        config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
6827
6828        config.refresh_notifications_encrypted().unwrap();
6829
6830        assert_eq!(
6831            config.notifications.ntfy.token_encrypted.as_deref(),
6832            Some("existing-ntfy-cipher")
6833        );
6834        assert_eq!(
6835            config.notifications.bark.device_key_encrypted.as_deref(),
6836            Some("existing-bark-cipher")
6837        );
6838    }
6839
6840    #[test]
6841    fn hydrate_skips_non_secret_entries() {
6842        let mut config = Config {
6843            env_vars: vec![EnvVarEntry {
6844                name: "PLAIN".to_string(),
6845                value: "original".to_string(),
6846                secret: false,
6847                value_encrypted: Some("should-be-ignored".to_string()),
6848                description: None,
6849            }],
6850            ..Default::default()
6851        };
6852
6853        config.hydrate_env_vars_from_encrypted();
6854        // Non-secret entry should keep its original value
6855        assert_eq!(config.env_vars[0].value, "original");
6856    }
6857
6858    #[test]
6859    fn default_config_has_empty_env_vars() {
6860        // `Config::default()` is a pure in-memory constructor (no disk read, no
6861        // env overrides), so this is independent of the developer's
6862        // `~/.bamboo/config.json` — no temp-dir isolation needed. Directly
6863        // asserts the #38 invariant that default() does not touch the filesystem.
6864        assert!(Config::default().env_vars.is_empty());
6865    }
6866
6867    #[test]
6868    fn serde_round_trip_with_env_vars() {
6869        let config = Config {
6870            env_vars: vec![
6871                EnvVarEntry {
6872                    name: "KEY1".to_string(),
6873                    value: "val1".to_string(),
6874                    secret: false,
6875                    value_encrypted: None,
6876                    description: Some("First key".to_string()),
6877                },
6878                EnvVarEntry {
6879                    name: "KEY2".to_string(),
6880                    value: "".to_string(), // on-disk secret has no plaintext
6881                    secret: true,
6882                    value_encrypted: Some("enc123".to_string()),
6883                    description: None,
6884                },
6885            ],
6886            ..Default::default()
6887        };
6888
6889        let json = serde_json::to_string(&config).unwrap();
6890        let restored: Config = serde_json::from_str(&json).unwrap();
6891
6892        assert_eq!(restored.env_vars.len(), 2);
6893        assert_eq!(restored.env_vars[0].name, "KEY1");
6894        assert_eq!(restored.env_vars[0].value, "val1");
6895        assert!(!restored.env_vars[0].secret);
6896        assert_eq!(restored.env_vars[1].name, "KEY2");
6897        assert!(restored.env_vars[1].secret);
6898        assert_eq!(
6899            restored.env_vars[1].value_encrypted.as_deref(),
6900            Some("enc123")
6901        );
6902    }
6903
6904    // ---- defaults.* model resolution tests ----
6905
6906    #[test]
6907    // fields set conditionally below
6908    #[allow(clippy::field_reassign_with_default)]
6909    fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
6910        let mut config = Config::default();
6911        config.provider = "openai".to_string();
6912        config.providers.openai = Some(OpenAIConfig {
6913            api_key: "test".to_string(),
6914            api_key_encrypted: None,
6915            base_url: None,
6916            model: Some("legacy-gpt-4o".to_string()),
6917            fast_model: None,
6918            vision_model: None,
6919            reasoning_effort: None,
6920            responses_only_models: vec![],
6921            request_overrides: None,
6922            extra: Default::default(),
6923            api_key_from_env: false,
6924        });
6925        config.features.provider_model_ref = true;
6926        config.defaults = Some(DefaultsConfig {
6927            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
6928            fast: None,
6929            task_summary: None,
6930            vision: None,
6931            memory_background: None,
6932            planning: None,
6933            search: None,
6934            code_review: None,
6935            sub_agent: None,
6936            subagent_models: Default::default(),
6937        });
6938
6939        assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
6940    }
6941
6942    #[test]
6943    // fields set conditionally below
6944    #[allow(clippy::field_reassign_with_default)]
6945    fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
6946        let mut config = Config::default();
6947        config.provider = "openai".to_string();
6948        config.providers.openai = Some(OpenAIConfig {
6949            api_key: "test".to_string(),
6950            api_key_encrypted: None,
6951            base_url: None,
6952            model: Some("legacy-gpt-4o".to_string()),
6953            fast_model: None,
6954            vision_model: None,
6955            reasoning_effort: None,
6956            responses_only_models: vec![],
6957            request_overrides: None,
6958            extra: Default::default(),
6959            api_key_from_env: false,
6960        });
6961        config.features.provider_model_ref = false;
6962        config.defaults = Some(DefaultsConfig {
6963            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
6964            fast: None,
6965            task_summary: None,
6966            vision: None,
6967            memory_background: None,
6968            planning: None,
6969            search: None,
6970            code_review: None,
6971            sub_agent: None,
6972            subagent_models: Default::default(),
6973        });
6974
6975        assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
6976    }
6977
6978    #[test]
6979    // fields set conditionally below
6980    #[allow(clippy::field_reassign_with_default)]
6981    fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
6982        let mut config = Config::default();
6983        config.provider = "openai".to_string();
6984        config.providers.openai = Some(OpenAIConfig {
6985            api_key: "test".to_string(),
6986            api_key_encrypted: None,
6987            base_url: None,
6988            model: Some("gpt-4o".to_string()),
6989            fast_model: Some("legacy-gpt-4o-mini".to_string()),
6990            vision_model: None,
6991            reasoning_effort: None,
6992            responses_only_models: vec![],
6993            request_overrides: None,
6994            extra: Default::default(),
6995            api_key_from_env: false,
6996        });
6997        config.features.provider_model_ref = true;
6998        config.defaults = Some(DefaultsConfig {
6999            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
7000            fast: Some(bamboo_domain::ProviderModelRef::new(
7001                "anthropic",
7002                "claude-3-5-haiku",
7003            )),
7004            task_summary: None,
7005            vision: None,
7006            memory_background: None,
7007            planning: None,
7008            search: None,
7009            code_review: None,
7010            sub_agent: None,
7011            subagent_models: Default::default(),
7012        });
7013
7014        assert_eq!(
7015            config.get_fast_model(),
7016            Some("claude-3-5-haiku".to_string())
7017        );
7018    }
7019
7020    #[test]
7021    // fields set conditionally below
7022    #[allow(clippy::field_reassign_with_default)]
7023    fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
7024        let mut config = Config::default();
7025        config.provider = "openai".to_string();
7026        config.providers.openai = Some(OpenAIConfig {
7027            api_key: "test".to_string(),
7028            api_key_encrypted: None,
7029            base_url: None,
7030            model: Some("gpt-4o".to_string()),
7031            fast_model: Some("legacy-gpt-4o-mini".to_string()),
7032            vision_model: None,
7033            reasoning_effort: None,
7034            responses_only_models: vec![],
7035            request_overrides: None,
7036            extra: Default::default(),
7037            api_key_from_env: false,
7038        });
7039        config.features.provider_model_ref = false;
7040        config.defaults = Some(DefaultsConfig {
7041            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
7042            fast: Some(bamboo_domain::ProviderModelRef::new(
7043                "anthropic",
7044                "claude-3-5-haiku",
7045            )),
7046            task_summary: None,
7047            vision: None,
7048            memory_background: None,
7049            planning: None,
7050            search: None,
7051            code_review: None,
7052            sub_agent: None,
7053            subagent_models: Default::default(),
7054        });
7055
7056        assert_eq!(
7057            config.get_fast_model(),
7058            Some("legacy-gpt-4o-mini".to_string())
7059        );
7060    }
7061
7062    #[test]
7063    // fields set conditionally below
7064    #[allow(clippy::field_reassign_with_default)]
7065    fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
7066        let mut config = Config::default();
7067        config.provider = "openai".to_string();
7068        config.features.provider_model_ref = true;
7069        config.defaults = Some(DefaultsConfig {
7070            chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
7071            fast: None,
7072            task_summary: None,
7073            vision: None,
7074            memory_background: None,
7075            planning: None,
7076            search: None,
7077            code_review: None,
7078            sub_agent: None,
7079            subagent_models: Default::default(),
7080        });
7081
7082        assert_eq!(
7083            config.get_fast_model(),
7084            Some("claude-3-7-sonnet".to_string())
7085        );
7086    }
7087
7088    #[test]
7089    // fields set conditionally below
7090    #[allow(clippy::field_reassign_with_default)]
7091    fn get_memory_background_model_prefers_defaults_memory_background() {
7092        let mut config = Config::default();
7093        config.provider = "openai".to_string();
7094        config.providers.openai = Some(OpenAIConfig {
7095            api_key: "test".to_string(),
7096            api_key_encrypted: None,
7097            base_url: None,
7098            model: Some("gpt-4o".to_string()),
7099            fast_model: Some("gpt-4o-mini".to_string()),
7100            vision_model: None,
7101            reasoning_effort: None,
7102            responses_only_models: vec![],
7103            request_overrides: None,
7104            extra: Default::default(),
7105            api_key_from_env: false,
7106        });
7107        config.features.provider_model_ref = true;
7108        config.defaults = Some(DefaultsConfig {
7109            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
7110            fast: Some(bamboo_domain::ProviderModelRef::new(
7111                "openai",
7112                "gpt-4o-mini",
7113            )),
7114            task_summary: None,
7115            vision: None,
7116            memory_background: Some(bamboo_domain::ProviderModelRef::new(
7117                "anthropic",
7118                "claude-3-5-haiku",
7119            )),
7120            planning: None,
7121            search: None,
7122            code_review: None,
7123            sub_agent: None,
7124            subagent_models: Default::default(),
7125        });
7126
7127        assert_eq!(
7128            config.get_memory_background_model(),
7129            Some("claude-3-5-haiku".to_string())
7130        );
7131    }
7132
7133    #[test]
7134    // fields set conditionally below
7135    #[allow(clippy::field_reassign_with_default)]
7136    fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
7137        let mut config = Config::default();
7138        config.provider = "openai".to_string();
7139        config.features.provider_model_ref = true;
7140        config.defaults = Some(DefaultsConfig {
7141            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
7142            fast: Some(bamboo_domain::ProviderModelRef::new(
7143                "anthropic",
7144                "claude-3-5-haiku",
7145            )),
7146            task_summary: None,
7147            vision: None,
7148            memory_background: None,
7149            planning: None,
7150            search: None,
7151            code_review: None,
7152            sub_agent: None,
7153            subagent_models: Default::default(),
7154        });
7155
7156        assert_eq!(
7157            config.get_memory_background_model(),
7158            Some("claude-3-5-haiku".to_string())
7159        );
7160    }
7161
7162    #[test]
7163    // fields set conditionally below
7164    #[allow(clippy::field_reassign_with_default)]
7165    fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
7166        let mut config = Config::default();
7167        config.provider = "openai".to_string();
7168        config.providers.openai = Some(OpenAIConfig {
7169            api_key: "test".to_string(),
7170            api_key_encrypted: None,
7171            base_url: None,
7172            model: Some("gpt-4o".to_string()),
7173            fast_model: Some("legacy-gpt-4o-mini".to_string()),
7174            vision_model: None,
7175            reasoning_effort: None,
7176            responses_only_models: vec![],
7177            request_overrides: None,
7178            extra: Default::default(),
7179            api_key_from_env: false,
7180        });
7181        config.features.provider_model_ref = false;
7182        config.defaults = Some(DefaultsConfig {
7183            chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
7184            fast: Some(bamboo_domain::ProviderModelRef::new(
7185                "anthropic",
7186                "claude-3-5-haiku",
7187            )),
7188            task_summary: None,
7189            vision: None,
7190            memory_background: Some(bamboo_domain::ProviderModelRef::new(
7191                "anthropic",
7192                "claude-3-5-haiku",
7193            )),
7194            planning: None,
7195            search: None,
7196            code_review: None,
7197            sub_agent: None,
7198            subagent_models: Default::default(),
7199        });
7200
7201        assert_eq!(
7202            config.get_memory_background_model(),
7203            Some("legacy-gpt-4o-mini".to_string())
7204        );
7205    }
7206
7207    // -------------------------------------------------------------------
7208    // `is_host_trusted` — plugin source-trust host allowlist (component
7209    // matching, not raw string-prefix matching; see the function's own docs
7210    // for the bypasses this closes).
7211    // -------------------------------------------------------------------
7212
7213    #[test]
7214    fn is_host_trusted_requires_https_scheme() {
7215        let hosts = vec!["github.com/bigduu/".to_string()];
7216        assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
7217        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
7218    }
7219
7220    #[test]
7221    fn is_host_trusted_is_case_insensitive_on_both_sides() {
7222        // A lowercase URL host against a mixed-case config entry...
7223        let hosts = vec!["GitHub.com/BigDuu/".to_string()];
7224        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
7225        // ...and a mixed-case URL host against a lowercase config entry.
7226        let hosts = vec!["github.com/bigduu/".to_string()];
7227        assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
7228    }
7229
7230    #[test]
7231    fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
7232        let hosts = vec!["trusted.example.com".to_string()];
7233        assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
7234        // Both demonstrated bypasses of a raw string-prefix match: gluing a
7235        // longer attacker-controlled label onto the trusted host, with or
7236        // without a separating dot.
7237        assert!(!is_host_trusted(
7238            "https://trusted.example.com.evil.com/x",
7239            &hosts
7240        ));
7241        assert!(!is_host_trusted(
7242            "https://trusted.example.comevil.com/x",
7243            &hosts
7244        ));
7245    }
7246
7247    #[test]
7248    fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
7249        // No trailing slash on the config entry's path component.
7250        let hosts = vec!["github.com/bigduu/".to_string()];
7251        assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
7252        assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
7253    }
7254
7255    #[test]
7256    fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
7257        let hosts = vec!["example.com".to_string()];
7258        assert!(is_host_trusted("https://example.com/", &hosts));
7259        assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
7260        // Still only that exact host — a bare-host entry must not become a
7261        // blanket "any host containing this string" match.
7262        assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
7263        assert!(!is_host_trusted("https://evil-example.com/", &hosts));
7264    }
7265
7266    #[test]
7267    fn is_host_trusted_uses_the_real_host_not_userinfo() {
7268        let hosts = vec!["github.com/bigduu/".to_string()];
7269        // `user@host` userinfo does not change the actual host.
7270        assert!(is_host_trusted(
7271            "https://someuser@github.com/bigduu/x",
7272            &hosts
7273        ));
7274        // A decoy host placed in the userinfo position must not be mistaken
7275        // for the real host — the real host here is `evil.com`.
7276        assert!(!is_host_trusted(
7277            "https://github.com@evil.com/bigduu/",
7278            &hosts
7279        ));
7280    }
7281
7282    #[test]
7283    fn is_host_trusted_ignores_an_explicit_port() {
7284        let hosts = vec!["github.com/bigduu/".to_string()];
7285        assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
7286    }
7287
7288    #[test]
7289    fn is_host_trusted_malformed_url_is_refused_without_panicking() {
7290        let hosts = vec!["github.com/bigduu/".to_string()];
7291        assert!(!is_host_trusted("not a url at all", &hosts));
7292        assert!(!is_host_trusted("", &hosts));
7293        assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); // no scheme
7294    }
7295
7296    #[test]
7297    fn is_host_trusted_normalizes_dot_segments_before_matching() {
7298        let hosts = vec!["github.com/bigduu/".to_string()];
7299        // `Url::parse` resolves `..` segments before `path()` is ever
7300        // consulted, so this cannot be used to escape the trusted prefix.
7301        assert!(!is_host_trusted(
7302            "https://github.com/bigduu/../evil/x",
7303            &hosts
7304        ));
7305        // A `..` that stays under the trusted prefix once resolved is fine.
7306        assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
7307    }
7308
7309    #[test]
7310    fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
7311        let mut config = Config::default();
7312        config.plugin_trust.trusted_hosts = vec![
7313            "  GitHub.com/BigDuu/ ".to_string(),
7314            "".to_string(),
7315            "   ".to_string(),
7316            "Example.COM".to_string(),
7317        ];
7318        config.normalize_plugin_trust_settings();
7319        assert_eq!(
7320            config.plugin_trust.trusted_hosts,
7321            vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
7322        );
7323    }
7324
7325    // -----------------------------------------------------------------
7326    // `plugin_trust.enforcement` — the persistent, config-level form of the
7327    // `--insecure` escape hatch.
7328    // -----------------------------------------------------------------
7329
7330    #[test]
7331    fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
7332        // A fresh `Config::default()` (nothing on disk at all).
7333        let config = Config::default();
7334        assert_eq!(
7335            config.plugin_trust.enforcement,
7336            PluginTrustEnforcement::Strict
7337        );
7338        assert!(!config.plugin_trust.enforcement_is_off());
7339
7340        // A `plugin_trust` object present in JSON but with NO `enforcement`
7341        // key at all (e.g. a config.json written before this field existed)
7342        // must ALSO deserialize to Strict, not fail or silently do something
7343        // else — additive/back-compat, matching `trusted_hosts`/
7344        // `trusted_keys`'s own `#[serde(default = ...)]` behavior.
7345        let json = serde_json::json!({
7346            "trusted_hosts": ["example.com"],
7347            "trusted_keys": [],
7348        });
7349        let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
7350        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
7351    }
7352
7353    #[test]
7354    fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
7355        for raw in ["off", "OFF", "Off", " off "] {
7356            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
7357                "enforcement": raw,
7358            }))
7359            .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
7360            assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
7361            assert!(trust.enforcement_is_off());
7362        }
7363        for raw in ["strict", "STRICT", " Strict "] {
7364            let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
7365                "enforcement": raw,
7366            }))
7367            .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
7368            assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
7369        }
7370
7371        let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
7372            "enforcement": "nonsense",
7373        }))
7374        .expect_err("an unrecognized string must be rejected, not silently default");
7375        assert!(err.to_string().contains("nonsense"));
7376    }
7377
7378    #[test]
7379    fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
7380        // A hand-edited config.json using a plain bool reads naturally: is
7381        // enforcement ON (`true`) or OFF (`false`)?
7382        let trust: PluginTrustConfig =
7383            serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
7384        assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
7385
7386        let trust: PluginTrustConfig =
7387            serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
7388        assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
7389    }
7390
7391    #[test]
7392    fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
7393        // Regardless of which accepted input form produced it, the
7394        // in-memory value always serializes back out as the canonical
7395        // string — this is what the dot-path `config set` setter's
7396        // round-trip check relies on (see `dot_path.rs`'s module docs).
7397        let trust = PluginTrustConfig {
7398            enforcement: PluginTrustEnforcement::Off,
7399            ..PluginTrustConfig::default()
7400        };
7401        let json = serde_json::to_value(&trust).unwrap();
7402        assert_eq!(json["enforcement"], "off");
7403
7404        let trust = PluginTrustConfig {
7405            enforcement: PluginTrustEnforcement::Strict,
7406            ..PluginTrustConfig::default()
7407        };
7408        let json = serde_json::to_value(&trust).unwrap();
7409        assert_eq!(json["enforcement"], "strict");
7410    }
7411
7412    #[test]
7413    fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
7414        // `normalize_plugin_trust_settings` only touches `trusted_hosts` —
7415        // confirm it's a true no-op on `enforcement` either way.
7416        let mut config = Config::default();
7417        config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
7418        config.normalize_plugin_trust_settings();
7419        assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
7420    }
7421
7422    #[test]
7423    fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
7424        // Confirms the dot-path `bamboo config set plugin_trust.enforcement
7425        // off` path actually works end to end through
7426        // `crate::dot_path::apply_dot_path_set` (the generic JSON-patch
7427        // setter), not just direct field assignment.
7428        let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
7429            "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
7430        )));
7431        assert_eq!(
7432            config.plugin_trust.enforcement,
7433            PluginTrustEnforcement::Strict
7434        );
7435
7436        let outcome = crate::dot_path::apply_dot_path_set(
7437            &config,
7438            "plugin_trust.enforcement",
7439            crate::dot_path::parse_cli_value("off"),
7440        )
7441        .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
7442        assert_eq!(
7443            outcome.config.plugin_trust.enforcement,
7444            PluginTrustEnforcement::Off
7445        );
7446
7447        // And back to strict.
7448        let outcome = crate::dot_path::apply_dot_path_set(
7449            &outcome.config,
7450            "plugin_trust.enforcement",
7451            crate::dot_path::parse_cli_value("strict"),
7452        )
7453        .expect("setting it back to strict should also round-trip");
7454        assert_eq!(
7455            outcome.config.plugin_trust.enforcement,
7456            PluginTrustEnforcement::Strict
7457        );
7458    }
7459}