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