Skip to main content

aft/
config_resolve.rs

1//! Pure aft.jsonc tier resolver.
2//!
3//! This module mirrors the TypeScript config pipeline for the core-consumed
4//! slice: raw JSONC tiers -> strict raw schema -> user/project trust merge ->
5//! flat [`Config`]. It intentionally performs no IO; callers supply the already
6//! read config documents.
7
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::path::PathBuf;
10
11use serde::de;
12use serde::{Deserialize, Deserializer};
13use serde_json::{Map, Value};
14
15use crate::config::{
16    BackupConfig, Config, InspectConfig, SandboxConfig, SemanticBackend, SemanticBackendConfig,
17    UserServerDef, MAX_SEMANTIC_QUERY_TIMEOUT_MS, MIN_SEMANTIC_QUERY_TIMEOUT_MS,
18};
19use crate::jsonc::strip_jsonc;
20
21const FOREGROUND_WAIT_WINDOW_DEFAULT_MS: u64 = 15_000;
22const FOREGROUND_WAIT_WINDOW_MIN_MS: u64 = 5_000;
23
24// Semantic budget clamps — restored from the deleted configure-time
25// parse_semantic_config so the tier-resolved Config matches the historical
26// clamping (zero-behavior-change relocation, not a new policy).
27const MAX_SEMANTIC_TIMEOUT_MS: u64 = 120_000;
28const MAX_SEMANTIC_BATCH_SIZE: usize = 1_024;
29
30const USER_ONLY_REASON: &str =
31    "security: this setting only honors user-level config and project values are ignored";
32const SEMANTIC_SECRET_REASON: &str =
33    "security: semantic backend credentials and endpoints must come from user-level config";
34const LSP_USER_ONLY_REASON: &str =
35    "security: LSP executable-origin and diagnostic-suppression settings must come from user-level config";
36
37/// One raw config document supplied by the host plugin.
38///
39/// `tier` is trusted process metadata stamped by the caller. The document body is
40/// never allowed to relabel itself.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ConfigTier {
43    pub tier: String,
44    pub source: String,
45    pub doc: String,
46}
47
48/// A project-tier key that was intentionally ignored at the user/project trust boundary.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct DroppedKey {
51    pub key: String,
52    pub tier: String,
53    pub reason: String,
54}
55
56/// A non-fatal config issue reported back to the caller during configuration.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ConfigWarning {
59    pub code: &'static str,
60    pub key: &'static str,
61    pub tier: String,
62    pub value: String,
63    pub message: String,
64}
65
66/// Diagnostics produced while resetting an existing runtime config.
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct ResolveDiagnostics {
69    pub dropped: Vec<DroppedKey>,
70    pub warnings: Vec<ConfigWarning>,
71}
72
73/// Fully resolved core config plus trust-boundary diagnostics.
74#[derive(Debug, Clone)]
75pub struct ResolveResult {
76    pub config: Config,
77    pub dropped: Vec<DroppedKey>,
78    pub warnings: Vec<ConfigWarning>,
79}
80
81/// Strict raw shape for aft.jsonc. This mirrors the TypeScript Zod schema, not
82/// the flat runtime [`Config`]. Privileged process-state fields are deliberately
83/// absent and therefore rejected by `deny_unknown_fields`.
84#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
85#[serde(default, deny_unknown_fields)]
86pub struct RawAftConfig {
87    #[serde(rename = "$schema")]
88    pub schema: Option<String>,
89    /// Master switch read by the TypeScript plugins before they start AFT.
90    /// The resolver accepts and merges it for validation, but does not copy it
91    /// into `Config`; when this is false the plugin returns before launching the
92    /// Rust process.
93    pub enabled: Option<bool>,
94    pub edit_mode: Option<RawEditMode>,
95    pub format_on_edit: Option<bool>,
96    #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
97    pub formatter_timeout_secs: Option<u32>,
98    #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
99    pub type_checker_timeout_secs: Option<u32>,
100    pub validate_on_edit: Option<RawValidateOnEdit>,
101    pub formatter: Option<HashMap<String, RawFormatter>>,
102    pub checker: Option<HashMap<String, RawChecker>>,
103    pub configure_warnings_delivery: Option<RawConfigureWarningsDelivery>,
104    pub hoist_builtin_tools: Option<bool>,
105    pub tool_surface: Option<RawToolSurface>,
106    pub disabled_tools: Option<Vec<String>>,
107    pub restrict_to_project_root: Option<bool>,
108    pub search_index: Option<bool>,
109    pub semantic_search: Option<bool>,
110    pub callgraph_store: Option<bool>,
111    #[serde(deserialize_with = "deserialize_opt_usize")]
112    pub callgraph_chunk_size: Option<usize>,
113    pub inspect: Option<RawInspect>,
114    pub backup: Option<RawBackup>,
115    pub sandbox: Option<RawSandbox>,
116    pub bash: Option<RawBash>,
117    pub experimental: Option<RawExperimental>,
118    pub lsp: Option<RawLsp>,
119    pub url_fetch_allow_private: Option<bool>,
120    pub semantic: Option<RawSemantic>,
121    pub auto_update: Option<bool>,
122    pub bridge: Option<RawBridge>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum RawEditMode {
127    Default,
128    Hashline,
129    Unknown(String),
130}
131
132impl<'de> Deserialize<'de> for RawEditMode {
133    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
134    where
135        D: Deserializer<'de>,
136    {
137        let value = String::deserialize(deserializer)?;
138        Ok(match value.as_str() {
139            "default" => Self::Default,
140            "hashline" => Self::Hashline,
141            _ => Self::Unknown(value),
142        })
143    }
144}
145
146#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "snake_case")]
148pub enum RawValidateOnEdit {
149    Syntax,
150    Full,
151}
152
153impl RawValidateOnEdit {
154    const fn as_str(self) -> &'static str {
155        match self {
156            Self::Syntax => "syntax",
157            Self::Full => "full",
158        }
159    }
160}
161
162#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
163#[serde(rename_all = "snake_case")]
164pub enum RawFormatter {
165    Biome,
166    Oxfmt,
167    Prettier,
168    Deno,
169    Ruff,
170    Black,
171    Rustfmt,
172    Goimports,
173    Gofmt,
174    None,
175}
176
177impl RawFormatter {
178    const fn as_str(self) -> &'static str {
179        match self {
180            Self::Biome => "biome",
181            Self::Oxfmt => "oxfmt",
182            Self::Prettier => "prettier",
183            Self::Deno => "deno",
184            Self::Ruff => "ruff",
185            Self::Black => "black",
186            Self::Rustfmt => "rustfmt",
187            Self::Goimports => "goimports",
188            Self::Gofmt => "gofmt",
189            Self::None => "none",
190        }
191    }
192}
193
194#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
195#[serde(rename_all = "snake_case")]
196pub enum RawChecker {
197    Tsc,
198    Tsgo,
199    Biome,
200    Pyright,
201    Ruff,
202    Cargo,
203    Go,
204    Staticcheck,
205    None,
206}
207
208impl RawChecker {
209    const fn as_str(self) -> &'static str {
210        match self {
211            Self::Tsc => "tsc",
212            Self::Tsgo => "tsgo",
213            Self::Biome => "biome",
214            Self::Pyright => "pyright",
215            Self::Ruff => "ruff",
216            Self::Cargo => "cargo",
217            Self::Go => "go",
218            Self::Staticcheck => "staticcheck",
219            Self::None => "none",
220        }
221    }
222}
223
224#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
225#[serde(rename_all = "snake_case")]
226pub enum RawConfigureWarningsDelivery {
227    Toast,
228    Log,
229    Chat,
230}
231
232#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
233#[serde(rename_all = "snake_case")]
234pub enum RawToolSurface {
235    Minimal,
236    Recommended,
237    All,
238}
239
240#[derive(Debug, Clone, Deserialize, PartialEq)]
241// Nested objects mirror TS sub-schemas, which are non-strict z.object (unknown
242// keys are silently stripped, the object survives). Only the TOP-LEVEL
243// RawAftConfig is strict (matches AftConfigSchema.strict()). Privileged denylist
244// fields are all top-level, so nested unknowns are harmlessly ignored.
245pub struct RawSemantic {
246    pub backend: Option<SemanticBackend>,
247    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
248    pub model: Option<String>,
249    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
250    pub base_url: Option<String>,
251    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
252    pub api_key_env: Option<String>,
253    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
254    pub timeout_ms: Option<u64>,
255    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
256    pub query_timeout_ms: Option<u64>,
257    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
258    pub max_batch_size: Option<usize>,
259    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
260    pub max_files: Option<usize>,
261}
262
263impl RawSemantic {
264    fn is_empty(&self) -> bool {
265        self.backend.is_none()
266            && self.model.is_none()
267            && self.base_url.is_none()
268            && self.api_key_env.is_none()
269            && self.timeout_ms.is_none()
270            && self.query_timeout_ms.is_none()
271            && self.max_batch_size.is_none()
272            && self.max_files.is_none()
273    }
274}
275
276#[derive(Debug, Clone, Deserialize, PartialEq)]
277pub struct RawLsp {
278    #[serde(default, deserialize_with = "deserialize_opt_lsp_servers")]
279    pub servers: Option<BTreeMap<String, RawLspServerEntry>>,
280    #[serde(
281        default,
282        deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec"
283    )]
284    pub disabled: Option<Vec<String>>,
285    pub python: Option<RawPythonLsp>,
286    pub diagnostics_on_edit: Option<bool>,
287    pub auto_install: Option<bool>,
288    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
289    pub grace_days: Option<u64>,
290    #[serde(default, deserialize_with = "deserialize_opt_versions_map")]
291    pub versions: Option<HashMap<String, String>>,
292}
293
294impl RawLsp {
295    fn is_empty(&self) -> bool {
296        self.servers.is_none()
297            && self.disabled.is_none()
298            && self.python.is_none()
299            && self.diagnostics_on_edit.is_none()
300            && self.auto_install.is_none()
301            && self.grace_days.is_none()
302            && self.versions.is_none()
303    }
304}
305
306#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
307#[serde(rename_all = "snake_case")]
308pub enum RawPythonLsp {
309    Pyright,
310    Ty,
311    Auto,
312}
313
314#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
315#[serde(default)]
316pub struct RawLspServerEntry {
317    #[serde(deserialize_with = "deserialize_opt_lsp_extensions")]
318    pub extensions: Option<Vec<String>>,
319    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
320    pub binary: Option<String>,
321    pub args: Option<Vec<String>>,
322    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec")]
323    pub root_markers: Option<Vec<String>>,
324    pub disabled: Option<bool>,
325    pub env: Option<HashMap<String, String>>,
326    pub initialization_options: Option<Value>,
327}
328
329#[derive(Debug, Clone, Deserialize, PartialEq)]
330#[serde(untagged)]
331pub enum RawBash {
332    Bool(bool),
333    Features(RawBashFeatures),
334}
335
336#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
337#[serde(default)]
338pub struct RawBashFeatures {
339    pub rewrite: Option<bool>,
340    pub compress: Option<bool>,
341    pub background: Option<bool>,
342    pub subagent_background: Option<bool>,
343    pub long_running_reminder_enabled: Option<bool>,
344    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
345    pub long_running_reminder_interval_ms: Option<u64>,
346    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
347    pub foreground_wait_window_ms: Option<u64>,
348}
349
350#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
351#[serde(default)]
352pub struct RawExperimental {
353    pub bash: Option<RawExperimentalBash>,
354    pub lsp_ty: Option<bool>,
355}
356
357impl RawExperimental {
358    fn is_empty(&self) -> bool {
359        self.bash.is_none() && self.lsp_ty.is_none()
360    }
361}
362
363#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
364#[serde(default)]
365pub struct RawExperimentalBash {
366    pub rewrite: Option<bool>,
367    pub compress: Option<bool>,
368    pub background: Option<bool>,
369    pub long_running_reminder_enabled: Option<bool>,
370    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
371    pub long_running_reminder_interval_ms: Option<u64>,
372}
373
374impl RawExperimentalBash {
375    fn has_any_value(&self) -> bool {
376        self.rewrite.is_some()
377            || self.compress.is_some()
378            || self.background.is_some()
379            || self.long_running_reminder_enabled.is_some()
380            || self.long_running_reminder_interval_ms.is_some()
381    }
382
383    fn has_legacy_feature_flag(&self) -> bool {
384        self.rewrite.is_some() || self.compress.is_some() || self.background.is_some()
385    }
386}
387
388#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
389#[serde(default)]
390pub struct RawInspect {
391    pub enabled: Option<bool>,
392    #[serde(deserialize_with = "deserialize_opt_nonnegative_f64")]
393    pub tier2_idle_minutes: Option<f64>,
394    pub categories: Option<HashMap<String, bool>>,
395    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
396    pub tier2_soft_deadline_ms: Option<u64>,
397    #[serde(deserialize_with = "deserialize_opt_drill_down_items")]
398    pub max_drill_down_items: Option<usize>,
399    pub duplicates: Option<RawInspectDuplicates>,
400}
401
402impl RawInspect {
403    fn is_empty(&self) -> bool {
404        self.enabled.is_none()
405            && self.tier2_idle_minutes.is_none()
406            && self.categories.is_none()
407            && self.tier2_soft_deadline_ms.is_none()
408            && self.max_drill_down_items.is_none()
409            && self.duplicates.is_none()
410    }
411}
412
413/// Only `expected_mirrors` survives here: `lower_bound`, `discard_cost`, and
414/// `anonymize` were accepted-but-never-read knobs (the scanner hardcodes its
415/// cost bounds and anonymization rules), so they were removed from the schema
416/// rather than wired up.
417#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
418#[serde(default)]
419pub struct RawInspectDuplicates {
420    pub expected_mirrors: Option<Vec<[String; 2]>>,
421}
422
423impl RawInspectDuplicates {
424    fn is_empty(&self) -> bool {
425        self.expected_mirrors.is_none()
426    }
427}
428
429#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
430#[serde(default)]
431pub struct RawBridge {
432    #[serde(deserialize_with = "deserialize_opt_bridge_request_timeout_ms")]
433    pub request_timeout_ms: Option<u64>,
434    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
435    pub hang_threshold: Option<u64>,
436}
437
438#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
439#[serde(default)]
440pub struct RawBackup {
441    pub enabled: Option<bool>,
442    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
443    pub max_depth: Option<usize>,
444    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
445    pub max_file_size: Option<u64>,
446}
447
448#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
449#[serde(default)]
450pub struct RawSandbox {
451    pub enabled: Option<bool>,
452    pub write_allow: Option<Vec<PathBuf>>,
453    pub read_deny: Option<Vec<PathBuf>>,
454}
455
456/// Resolve raw user/project config tiers into the flat core [`Config`].
457///
458/// Empty input is NOT special-cased: no config file is equivalent to an empty
459/// config object, so it still flows through the resolver and picks up the bash
460/// surface default (recommended ⇒ bash on), matching the TypeScript pipeline
461/// which always runs `resolveProjectOverridesForConfigure` even on `{}`.
462pub fn resolve_config(tiers: &[ConfigTier]) -> ResolveResult {
463    let mut merged = RawAftConfig::default();
464    let mut dropped = Vec::new();
465    let mut warnings = Vec::new();
466
467    for tier in tiers {
468        let Some(raw) = parse_tier(tier) else {
469            continue;
470        };
471        if let Some(RawEditMode::Unknown(value)) = raw.edit_mode.as_ref() {
472            warnings.push(ConfigWarning {
473                code: "invalid_edit_mode",
474                key: "edit_mode",
475                tier: tier.tier.clone(),
476                value: value.clone(),
477                message: format!("Unknown edit_mode value {value:?}; falling back to \"default\""),
478            });
479        }
480
481        if tier.tier == "user" {
482            merge_trusted_config(&mut merged, raw);
483        } else {
484            record_project_drops(&raw, &tier.tier, &mut dropped);
485            merge_project_config(&mut merged, raw);
486        }
487    }
488
489    let mut config = Config::default();
490    apply_resolved_config(&merged, &mut config);
491    ResolveResult {
492        config,
493        dropped,
494        warnings,
495    }
496}
497
498/// Resolve raw config tiers into the core-domain config and RESET it onto an
499/// existing `base`, preserving only `base`'s process-state fields (storage_dir,
500/// harness, lsp_paths_extra, bash_permissions, …). This is the configure-path
501/// entry.
502///
503/// RESET, not overlay: the core-domain config is rebuilt from DEFAULT + the
504/// supplied tiers, so a field absent from the tiers returns to its default —
505/// it NEVER keeps `base`'s prior value. This closes a cross-bind privilege
506/// escalation: under the subc daemon a single `AppContext` per project root is
507/// shared across harness identities, and `configure` seeds `base` from the
508/// previous bind's config. With the old overlay semantics, a later low-trust
509/// bind (e.g. `mcp:*` or `fed:*`) that omitted a field inherited an earlier high-trust
510/// bind's capability for it (confirmed on the wire: `url_fetch_allow_private`
511/// SSRF, and `lsp_servers` arbitrary-binary). Reset-onto-default makes the
512/// resolved core config a pure function of THIS bind's own tiers.
513///
514/// Parity-safe by construction: this routes through [`resolve_config`] (the same
515/// entry the cross-language parity gate validates), which builds onto
516/// `Config::default()` — so reset-onto-default == overlay-onto-default there and
517/// no parity/unit fixture changes. Only this configure path, seeded from a prior
518/// config, changes behavior — which is exactly the leak site.
519///
520/// Process-state fields are not part of `RawAftConfig`; they are carried from
521/// `base` here and re-applied by `handle_configure`'s flat-param parsing
522/// afterwards, so plugin-mode behavior is unchanged (the plugin re-sends them on
523/// every configure). They are also unreachable as a subc escalation vector: a
524/// subc RouteBind sends only `config:[tiers]`, never the flat process-state
525/// params, so they stay at default for every subc bind regardless.
526pub fn resolve_config_onto(tiers: &[ConfigTier], base: &mut Config) -> Vec<DroppedKey> {
527    resolve_config_onto_with_diagnostics(tiers, base).dropped
528}
529
530/// Reset a runtime config while retaining both trust-boundary drops and
531/// non-fatal value warnings for the configure response.
532pub fn resolve_config_onto_with_diagnostics(
533    tiers: &[ConfigTier],
534    base: &mut Config,
535) -> ResolveDiagnostics {
536    let ResolveResult {
537        mut config,
538        dropped,
539        warnings,
540    } = resolve_config(tiers);
541    carry_process_state(base, &mut config);
542    *base = config;
543    ResolveDiagnostics { dropped, warnings }
544}
545
546/// Carry the process-state (non-`RawAftConfig`) fields from `base` onto a
547/// freshly-resolved core config. EVERY `Config` field not copied here is
548/// core-domain and intentionally comes from the resolved tiers (reset). Keeping
549/// this list complete is load-bearing: a core field accidentally copied here
550/// would re-introduce cross-bind inheritance for it.
551fn carry_process_state(base: &Config, resolved: &mut Config) {
552    resolved.project_root = base.project_root.clone();
553    resolved.harness = base.harness.clone();
554    resolved.validation_depth = base.validation_depth;
555    resolved.checkpoint_ttl_hours = base.checkpoint_ttl_hours;
556    resolved.max_symbol_depth = base.max_symbol_depth;
557    resolved.diagnostic_cache_size = base.diagnostic_cache_size;
558    resolved.aft_search_registered = base.aft_search_registered;
559    resolved.max_background_bash_tasks = base.max_background_bash_tasks;
560    resolved.bash_permissions = base.bash_permissions;
561    resolved.search_index_max_file_size = base.search_index_max_file_size;
562    resolved.storage_dir = base.storage_dir.clone();
563    resolved.lsp_paths_extra = base.lsp_paths_extra.clone();
564    resolved.lsp_auto_install_binaries = base.lsp_auto_install_binaries.clone();
565    resolved.lsp_inflight_installs = base.lsp_inflight_installs.clone();
566}
567
568fn parse_tier(tier: &ConfigTier) -> Option<RawAftConfig> {
569    let stripped = strip_jsonc(&tier.doc);
570    let value = serde_json::from_str::<Value>(&stripped).ok()?;
571    let Value::Object(map) = value else {
572        return None;
573    };
574
575    match serde_json::from_value::<RawAftConfig>(Value::Object(map.clone())) {
576        Ok(config) => Some(config),
577        Err(_) => Some(parse_config_partially(map)),
578    }
579}
580
581fn parse_config_partially(raw_config: Map<String, Value>) -> RawAftConfig {
582    let mut partial = RawAftConfig::default();
583
584    for (key, value) in raw_config {
585        let mut one_field = Map::new();
586        one_field.insert(key, value);
587        if let Ok(section) = serde_json::from_value::<RawAftConfig>(Value::Object(one_field)) {
588            merge_trusted_config(&mut partial, section);
589        }
590    }
591
592    partial
593}
594
595fn merge_trusted_config(base: &mut RawAftConfig, override_config: RawAftConfig) {
596    if override_config.schema.is_some() {
597        base.schema = override_config.schema;
598    }
599    if override_config.enabled.is_some() {
600        base.enabled = override_config.enabled;
601    }
602    if override_config.edit_mode.is_some() {
603        base.edit_mode = override_config.edit_mode;
604    }
605    if override_config.format_on_edit.is_some() {
606        base.format_on_edit = override_config.format_on_edit;
607    }
608    if override_config.formatter_timeout_secs.is_some() {
609        base.formatter_timeout_secs = override_config.formatter_timeout_secs;
610    }
611    if override_config.type_checker_timeout_secs.is_some() {
612        base.type_checker_timeout_secs = override_config.type_checker_timeout_secs;
613    }
614    if override_config.validate_on_edit.is_some() {
615        base.validate_on_edit = override_config.validate_on_edit;
616    }
617    if override_config.formatter.is_some() {
618        base.formatter = override_config.formatter;
619    }
620    if override_config.checker.is_some() {
621        base.checker = override_config.checker;
622    }
623    if override_config.configure_warnings_delivery.is_some() {
624        base.configure_warnings_delivery = override_config.configure_warnings_delivery;
625    }
626    if override_config.hoist_builtin_tools.is_some() {
627        base.hoist_builtin_tools = override_config.hoist_builtin_tools;
628    }
629    if override_config.tool_surface.is_some() {
630        base.tool_surface = override_config.tool_surface;
631    }
632    if override_config.disabled_tools.is_some() {
633        base.disabled_tools = override_config.disabled_tools;
634    }
635    if override_config.restrict_to_project_root.is_some() {
636        base.restrict_to_project_root = override_config.restrict_to_project_root;
637    }
638    if override_config.search_index.is_some() {
639        base.search_index = override_config.search_index;
640    }
641    if override_config.semantic_search.is_some() {
642        base.semantic_search = override_config.semantic_search;
643    }
644    if override_config.callgraph_store.is_some() {
645        base.callgraph_store = override_config.callgraph_store;
646    }
647    if override_config.callgraph_chunk_size.is_some() {
648        base.callgraph_chunk_size = override_config.callgraph_chunk_size;
649    }
650    if override_config.inspect.is_some() {
651        base.inspect = override_config.inspect;
652    }
653    if override_config.backup.is_some() {
654        base.backup = override_config.backup;
655    }
656    if override_config.sandbox.is_some() {
657        base.sandbox = override_config.sandbox;
658    }
659    if override_config.bash.is_some() {
660        base.bash = override_config.bash;
661    }
662    if override_config.experimental.is_some() {
663        base.experimental = override_config.experimental;
664    }
665    if override_config.lsp.is_some() {
666        base.lsp = override_config.lsp;
667    }
668    if override_config.url_fetch_allow_private.is_some() {
669        base.url_fetch_allow_private = override_config.url_fetch_allow_private;
670    }
671    if override_config.semantic.is_some() {
672        base.semantic = override_config.semantic;
673    }
674    if override_config.auto_update.is_some() {
675        base.auto_update = override_config.auto_update;
676    }
677    if override_config.bridge.is_some() {
678        base.bridge = override_config.bridge;
679    }
680}
681
682fn merge_project_config(base: &mut RawAftConfig, project: RawAftConfig) {
683    // Project-safe shallow top-level fields.
684    if project.enabled.is_some() {
685        base.enabled = project.enabled;
686    }
687    if project.edit_mode.is_some() {
688        base.edit_mode = project.edit_mode;
689    }
690    if project.format_on_edit.is_some() {
691        base.format_on_edit = project.format_on_edit;
692    }
693    if project.validate_on_edit.is_some() {
694        base.validate_on_edit = project.validate_on_edit;
695    }
696    if project.configure_warnings_delivery.is_some() {
697        base.configure_warnings_delivery = project.configure_warnings_delivery;
698    }
699    if project.hoist_builtin_tools.is_some() {
700        base.hoist_builtin_tools = project.hoist_builtin_tools;
701    }
702    if project.tool_surface.is_some() {
703        base.tool_surface = project.tool_surface;
704    }
705    if project.search_index.is_some() {
706        base.search_index = project.search_index;
707    }
708    if project.semantic_search.is_some() {
709        base.semantic_search = project.semantic_search;
710    }
711    if project.callgraph_store.is_some() {
712        base.callgraph_store = project.callgraph_store;
713    }
714    if project.callgraph_chunk_size.is_some() {
715        base.callgraph_chunk_size = project.callgraph_chunk_size;
716    }
717
718    merge_formatter_map(&mut base.formatter, project.formatter);
719    merge_checker_map(&mut base.checker, project.checker);
720    merge_disabled_tools(&mut base.disabled_tools, project.disabled_tools);
721    base.semantic = merge_semantic_config(base.semantic.clone(), project.semantic);
722    base.lsp = merge_lsp_config(base.lsp.clone(), project.lsp);
723    base.experimental = merge_experimental_config(base.experimental.clone(), project.experimental);
724    base.bash = merge_bash_config(base.bash.clone(), project.bash);
725    base.inspect = merge_inspect_config(base.inspect.clone(), project.inspect);
726    base.sandbox = merge_project_sandbox(base.sandbox.clone(), project.sandbox);
727}
728
729fn merge_project_sandbox(
730    base: Option<RawSandbox>,
731    project: Option<RawSandbox>,
732) -> Option<RawSandbox> {
733    let Some(project) = project else {
734        return base;
735    };
736    let mut sandbox = base.unwrap_or_default();
737    if let Some(project_denies) = project.read_deny {
738        let denies = sandbox.read_deny.get_or_insert_with(Vec::new);
739        for path in project_denies {
740            if !denies.contains(&path) {
741                denies.push(path);
742            }
743        }
744    }
745    // A project may ENABLE the sandbox for itself (hardening is one-way): a
746    // repo can opt its own bash into kernel confinement, but a project-tier
747    // `enabled: false` can never switch off what the user turned on.
748    if project.enabled == Some(true) {
749        sandbox.enabled = Some(true);
750    }
751    (sandbox.enabled.is_some() || sandbox.write_allow.is_some() || sandbox.read_deny.is_some())
752        .then_some(sandbox)
753}
754
755fn merge_formatter_map(
756    base: &mut Option<HashMap<String, RawFormatter>>,
757    override_map: Option<HashMap<String, RawFormatter>>,
758) {
759    let Some(override_map) = override_map else {
760        return;
761    };
762    if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
763        return;
764    }
765    let target = base.get_or_insert_with(HashMap::new);
766    target.extend(override_map);
767}
768
769fn merge_checker_map(
770    base: &mut Option<HashMap<String, RawChecker>>,
771    override_map: Option<HashMap<String, RawChecker>>,
772) {
773    let Some(override_map) = override_map else {
774        return;
775    };
776    if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
777        return;
778    }
779    let target = base.get_or_insert_with(HashMap::new);
780    target.extend(override_map);
781}
782
783fn merge_disabled_tools(base: &mut Option<Vec<String>>, override_tools: Option<Vec<String>>) {
784    let Some(override_tools) = override_tools else {
785        return;
786    };
787    let mut merged = Vec::new();
788    let mut seen = HashSet::new();
789    for tool in base.iter().flatten() {
790        if seen.insert(tool.clone()) {
791            merged.push(tool.clone());
792        }
793    }
794    for tool in override_tools
795        .iter()
796        .filter(|tool| tool.as_str() != "aft_safety")
797    {
798        if seen.insert(tool.clone()) {
799            merged.push(tool.clone());
800        }
801    }
802    if !merged.is_empty() {
803        *base = Some(merged);
804    }
805}
806
807fn merge_semantic_config(
808    base: Option<RawSemantic>,
809    override_semantic: Option<RawSemantic>,
810) -> Option<RawSemantic> {
811    let mut semantic = base.unwrap_or(RawSemantic {
812        backend: None,
813        model: None,
814        base_url: None,
815        api_key_env: None,
816        timeout_ms: None,
817        query_timeout_ms: None,
818        max_batch_size: None,
819        max_files: None,
820    });
821
822    if let Some(project) = override_semantic {
823        if project.model.is_some() {
824            semantic.model = project.model;
825        }
826        if project.timeout_ms.is_some() {
827            semantic.timeout_ms = project.timeout_ms;
828        }
829        if project.max_batch_size.is_some() {
830            semantic.max_batch_size = project.max_batch_size;
831        }
832        if project.max_files.is_some() {
833            semantic.max_files = project.max_files;
834        }
835    }
836
837    (!semantic.is_empty()).then_some(semantic)
838}
839
840fn merge_lsp_config(base: Option<RawLsp>, override_lsp: Option<RawLsp>) -> Option<RawLsp> {
841    let mut lsp = base.unwrap_or(RawLsp {
842        servers: None,
843        disabled: None,
844        python: None,
845        diagnostics_on_edit: None,
846        auto_install: None,
847        grace_days: None,
848        versions: None,
849    });
850
851    if let Some(project) = override_lsp {
852        if project.python.is_some() {
853            lsp.python = project.python;
854        }
855        if project.diagnostics_on_edit.is_some() {
856            lsp.diagnostics_on_edit = project.diagnostics_on_edit;
857        }
858    }
859
860    (!lsp.is_empty()).then_some(lsp)
861}
862
863fn merge_experimental_config(
864    base: Option<RawExperimental>,
865    override_experimental: Option<RawExperimental>,
866) -> Option<RawExperimental> {
867    let Some(override_experimental) = override_experimental else {
868        return base;
869    };
870
871    let mut experimental = base.unwrap_or_default();
872    experimental.lsp_ty = override_experimental.lsp_ty.or(experimental.lsp_ty);
873    experimental.bash = merge_experimental_bash(experimental.bash, override_experimental.bash);
874
875    (!experimental.is_empty()).then_some(experimental)
876}
877
878fn merge_experimental_bash(
879    base: Option<RawExperimentalBash>,
880    override_bash: Option<RawExperimentalBash>,
881) -> Option<RawExperimentalBash> {
882    let Some(override_bash) = override_bash else {
883        return base;
884    };
885    let mut bash = base.unwrap_or_default();
886    bash.rewrite = override_bash.rewrite.or(bash.rewrite);
887    bash.compress = override_bash.compress.or(bash.compress);
888    bash.background = override_bash.background.or(bash.background);
889    bash.long_running_reminder_enabled = override_bash
890        .long_running_reminder_enabled
891        .or(bash.long_running_reminder_enabled);
892    bash.long_running_reminder_interval_ms = override_bash
893        .long_running_reminder_interval_ms
894        .or(bash.long_running_reminder_interval_ms);
895
896    bash.has_any_value().then_some(bash)
897}
898
899fn merge_bash_config(base: Option<RawBash>, override_bash: Option<RawBash>) -> Option<RawBash> {
900    match (base, override_bash) {
901        (None, None) => None,
902        (None, Some(override_bash)) => Some(override_bash),
903        (Some(base), None) => Some(base),
904        (Some(base), Some(override_bash)) => {
905            let base = expand_bash_for_merge(&base);
906            let override_features = expand_bash_for_merge(&override_bash);
907            Some(RawBash::Features(RawBashFeatures {
908                rewrite: override_features.rewrite.or(base.rewrite),
909                compress: override_features.compress.or(base.compress),
910                background: override_features.background.or(base.background),
911                subagent_background: override_features
912                    .subagent_background
913                    .or(base.subagent_background),
914                long_running_reminder_enabled: override_features
915                    .long_running_reminder_enabled
916                    .or(base.long_running_reminder_enabled),
917                long_running_reminder_interval_ms: override_features
918                    .long_running_reminder_interval_ms
919                    .or(base.long_running_reminder_interval_ms),
920                foreground_wait_window_ms: override_features
921                    .foreground_wait_window_ms
922                    .or(base.foreground_wait_window_ms),
923            }))
924        }
925    }
926}
927
928fn expand_bash_for_merge(value: &RawBash) -> RawBashFeatures {
929    match value {
930        RawBash::Bool(enabled) => RawBashFeatures {
931            rewrite: Some(*enabled),
932            compress: Some(*enabled),
933            background: Some(*enabled),
934            subagent_background: None,
935            long_running_reminder_enabled: None,
936            long_running_reminder_interval_ms: None,
937            foreground_wait_window_ms: None,
938        },
939        RawBash::Features(features) => features.clone(),
940    }
941}
942
943fn merge_inspect_config(
944    base: Option<RawInspect>,
945    override_inspect: Option<RawInspect>,
946) -> Option<RawInspect> {
947    let Some(override_inspect) = override_inspect else {
948        return base;
949    };
950
951    let mut inspect = base.unwrap_or_default();
952    inspect.enabled = override_inspect.enabled.or(inspect.enabled);
953    inspect.tier2_idle_minutes = override_inspect
954        .tier2_idle_minutes
955        .or(inspect.tier2_idle_minutes);
956    inspect.categories = override_inspect.categories.or(inspect.categories);
957    inspect.tier2_soft_deadline_ms = override_inspect
958        .tier2_soft_deadline_ms
959        .or(inspect.tier2_soft_deadline_ms);
960    inspect.max_drill_down_items = override_inspect
961        .max_drill_down_items
962        .or(inspect.max_drill_down_items);
963    inspect.duplicates = merge_inspect_duplicates(inspect.duplicates, override_inspect.duplicates);
964
965    (!inspect.is_empty()).then_some(inspect)
966}
967
968fn merge_inspect_duplicates(
969    base: Option<RawInspectDuplicates>,
970    override_duplicates: Option<RawInspectDuplicates>,
971) -> Option<RawInspectDuplicates> {
972    let Some(override_duplicates) = override_duplicates else {
973        return base;
974    };
975
976    let mut duplicates = base.unwrap_or_default();
977    duplicates.expected_mirrors = override_duplicates
978        .expected_mirrors
979        .or(duplicates.expected_mirrors);
980
981    (!duplicates.is_empty()).then_some(duplicates)
982}
983
984fn record_project_drops(raw: &RawAftConfig, tier: &str, dropped: &mut Vec<DroppedKey>) {
985    if raw.restrict_to_project_root.is_some() {
986        push_drop(dropped, "restrict_to_project_root", tier, USER_ONLY_REASON);
987    }
988    if raw.url_fetch_allow_private.is_some() {
989        push_drop(dropped, "url_fetch_allow_private", tier, USER_ONLY_REASON);
990    }
991    if raw.formatter_timeout_secs.is_some() {
992        push_drop(dropped, "formatter_timeout_secs", tier, USER_ONLY_REASON);
993    }
994    if raw.type_checker_timeout_secs.is_some() {
995        push_drop(dropped, "type_checker_timeout_secs", tier, USER_ONLY_REASON);
996    }
997    if raw.auto_update.is_some() {
998        push_drop(dropped, "auto_update", tier, USER_ONLY_REASON);
999    }
1000    if raw.bridge.is_some() {
1001        push_drop(dropped, "bridge", tier, USER_ONLY_REASON);
1002    }
1003    if raw.backup.is_some() {
1004        push_drop(dropped, "backup", tier, USER_ONLY_REASON);
1005    }
1006    if let Some(sandbox) = &raw.sandbox {
1007        // enabled:true is an accepted project-tier hardening opt-in (merged by
1008        // merge_project_sandbox); only the weakening direction is dropped.
1009        if sandbox.enabled == Some(false) {
1010            push_drop(dropped, "sandbox.enabled", tier, USER_ONLY_REASON);
1011        }
1012        if sandbox.write_allow.is_some() {
1013            push_drop(dropped, "sandbox.write_allow", tier, USER_ONLY_REASON);
1014        }
1015    }
1016    if raw
1017        .disabled_tools
1018        .as_ref()
1019        .is_some_and(|tools| tools.iter().any(|tool| tool == "aft_safety"))
1020    {
1021        push_drop(dropped, "disabled_tools.aft_safety", tier, USER_ONLY_REASON);
1022    }
1023
1024    if let Some(semantic) = &raw.semantic {
1025        if semantic.backend.is_some() {
1026            push_drop(dropped, "semantic.backend", tier, SEMANTIC_SECRET_REASON);
1027        }
1028        if semantic.base_url.is_some() {
1029            push_drop(dropped, "semantic.base_url", tier, SEMANTIC_SECRET_REASON);
1030        }
1031        if semantic.api_key_env.is_some() {
1032            push_drop(
1033                dropped,
1034                "semantic.api_key_env",
1035                tier,
1036                SEMANTIC_SECRET_REASON,
1037            );
1038        }
1039        if semantic.query_timeout_ms.is_some() {
1040            push_drop(dropped, "semantic.query_timeout_ms", tier, USER_ONLY_REASON);
1041        }
1042    }
1043
1044    if let Some(lsp) = &raw.lsp {
1045        if lsp.servers.is_some() {
1046            push_drop(dropped, "lsp.servers", tier, LSP_USER_ONLY_REASON);
1047        }
1048        if lsp.versions.is_some() {
1049            push_drop(dropped, "lsp.versions", tier, LSP_USER_ONLY_REASON);
1050        }
1051        if lsp.auto_install.is_some() {
1052            push_drop(dropped, "lsp.auto_install", tier, LSP_USER_ONLY_REASON);
1053        }
1054        if lsp.grace_days.is_some() {
1055            push_drop(dropped, "lsp.grace_days", tier, LSP_USER_ONLY_REASON);
1056        }
1057        if lsp.disabled.is_some() {
1058            push_drop(dropped, "lsp.disabled", tier, LSP_USER_ONLY_REASON);
1059        }
1060    }
1061}
1062
1063fn push_drop(dropped: &mut Vec<DroppedKey>, key: &str, tier: &str, reason: &str) {
1064    dropped.push(DroppedKey {
1065        key: key.to_string(),
1066        tier: tier.to_string(),
1067        reason: reason.to_string(),
1068    });
1069}
1070
1071/// Overlay the resolved core-domain fields from `raw` onto `config`. Scalar
1072/// fields are only written when present in the merged tiers (preserving the
1073/// caller's base for absent keys); semantic/inspect/lsp are core-domain and are
1074/// fully resolved from the tiers. Process-state fields on `config` are never
1075/// touched (they are not part of `RawAftConfig`).
1076fn apply_resolved_config(raw: &RawAftConfig, config: &mut Config) {
1077    config.hashline_enabled = matches!(raw.edit_mode, Some(RawEditMode::Hashline));
1078    if let Some(value) = raw.format_on_edit {
1079        config.format_on_edit = value;
1080    }
1081    if let Some(value) = raw.formatter_timeout_secs {
1082        config.formatter_timeout_secs = value;
1083    }
1084    if let Some(value) = raw.type_checker_timeout_secs {
1085        config.type_checker_timeout_secs = value;
1086    }
1087    if let Some(value) = raw.validate_on_edit {
1088        config.validate_on_edit = Some(value.as_str().to_string());
1089    }
1090    if let Some(formatter) = &raw.formatter {
1091        config.formatter = formatter
1092            .iter()
1093            .map(|(language, formatter)| (language.clone(), formatter.as_str().to_string()))
1094            .collect();
1095    }
1096    if let Some(checker) = &raw.checker {
1097        config.checker = checker
1098            .iter()
1099            .map(|(language, checker)| (language.clone(), checker.as_str().to_string()))
1100            .collect();
1101    }
1102    if let Some(value) = raw.restrict_to_project_root {
1103        config.restrict_to_project_root = value;
1104    }
1105    if let Some(value) = raw.search_index {
1106        config.search_index = value;
1107    }
1108    if let Some(value) = raw.semantic_search {
1109        config.semantic_search = value;
1110    }
1111    if let Some(value) = raw.callgraph_store {
1112        config.callgraph_store = value;
1113    }
1114    if let Some(value) = raw.callgraph_chunk_size {
1115        config.callgraph_chunk_size = value;
1116    }
1117    if let Some(value) = raw.url_fetch_allow_private {
1118        config.url_fetch_allow_private = value;
1119    }
1120    config.semantic = resolve_semantic_config(raw.semantic.as_ref());
1121    config.inspect = resolve_inspect_config(raw.inspect.as_ref());
1122    config.backup = resolve_backup_config(raw.backup.as_ref());
1123    config.sandbox = resolve_sandbox_config(raw.sandbox.as_ref());
1124    resolve_lsp_config(raw, config);
1125    resolve_bash_fields(raw, config);
1126}
1127
1128fn resolve_semantic_config(raw: Option<&RawSemantic>) -> SemanticBackendConfig {
1129    let mut semantic = SemanticBackendConfig::default();
1130    let Some(raw) = raw else {
1131        return semantic;
1132    };
1133
1134    if let Some(value) = raw.backend {
1135        semantic.backend = value;
1136    }
1137    if let Some(value) = &raw.model {
1138        semantic.model = value.clone();
1139    }
1140    if let Some(value) = &raw.base_url {
1141        semantic.base_url = Some(value.clone());
1142    }
1143    if let Some(value) = &raw.api_key_env {
1144        semantic.api_key_env = Some(value.clone());
1145    }
1146    if let Some(value) = raw.timeout_ms {
1147        semantic.timeout_ms = value.min(MAX_SEMANTIC_TIMEOUT_MS);
1148    }
1149    if let Some(value) = raw.query_timeout_ms {
1150        semantic.query_timeout_ms =
1151            value.clamp(MIN_SEMANTIC_QUERY_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS);
1152    }
1153    if let Some(value) = raw.max_batch_size {
1154        semantic.max_batch_size = value.min(MAX_SEMANTIC_BATCH_SIZE);
1155    }
1156    if let Some(value) = raw.max_files {
1157        semantic.max_files = value;
1158    }
1159
1160    semantic
1161}
1162
1163fn resolve_inspect_config(raw: Option<&RawInspect>) -> InspectConfig {
1164    let mut inspect = InspectConfig::default();
1165    let Some(raw) = raw else {
1166        return inspect;
1167    };
1168    if let Some(enabled) = raw.enabled {
1169        inspect.enabled = enabled;
1170    }
1171    if let Some(expected_mirrors) = raw
1172        .duplicates
1173        .as_ref()
1174        .and_then(|duplicates| duplicates.expected_mirrors.clone())
1175    {
1176        inspect.duplicates.expected_mirrors = expected_mirrors;
1177    }
1178    inspect
1179}
1180
1181fn resolve_backup_config(raw: Option<&RawBackup>) -> BackupConfig {
1182    let mut backup = BackupConfig::default();
1183    if let Some(raw) = raw {
1184        if raw.enabled.is_some() {
1185            backup.enabled = raw.enabled;
1186        }
1187        if raw.max_depth.is_some() {
1188            backup.max_depth = raw.max_depth;
1189        }
1190        if raw.max_file_size.is_some() {
1191            backup.max_file_size = raw.max_file_size;
1192        }
1193    }
1194    backup
1195}
1196
1197fn resolve_sandbox_config(raw: Option<&RawSandbox>) -> SandboxConfig {
1198    let Some(raw) = raw else {
1199        return SandboxConfig::default();
1200    };
1201    SandboxConfig {
1202        enabled: raw.enabled.unwrap_or(false),
1203        write_allow: raw.write_allow.clone().unwrap_or_default(),
1204        read_deny: raw.read_deny.clone().unwrap_or_default(),
1205    }
1206}
1207
1208fn resolve_lsp_config(raw: &RawAftConfig, config: &mut Config) {
1209    let lsp = raw.lsp.as_ref();
1210    let mut disabled: HashSet<String> = lsp
1211        .and_then(|lsp| lsp.disabled.as_ref())
1212        .into_iter()
1213        .flatten()
1214        .map(|value| value.to_ascii_lowercase())
1215        .collect();
1216    let mut experimental_ty = raw
1217        .experimental
1218        .as_ref()
1219        .and_then(|experimental| experimental.lsp_ty);
1220
1221    match lsp.and_then(|lsp| lsp.python).unwrap_or(RawPythonLsp::Auto) {
1222        RawPythonLsp::Ty => {
1223            experimental_ty = Some(true);
1224            disabled.insert("python".to_string());
1225        }
1226        RawPythonLsp::Pyright => {
1227            experimental_ty = Some(false);
1228            disabled.insert("ty".to_string());
1229        }
1230        RawPythonLsp::Auto => {}
1231    }
1232
1233    if let Some(value) = experimental_ty {
1234        config.experimental_lsp_ty = value;
1235    }
1236
1237    if let Some(value) = lsp.and_then(|lsp| lsp.diagnostics_on_edit) {
1238        config.diagnostics_on_edit = value;
1239    }
1240
1241    if let Some(servers) = lsp.and_then(|lsp| lsp.servers.as_ref()) {
1242        config.lsp_servers = servers
1243            .iter()
1244            .map(|(id, server)| UserServerDef {
1245                id: id.clone(),
1246                extensions: server
1247                    .extensions
1248                    .clone()
1249                    .unwrap_or_default()
1250                    .into_iter()
1251                    .map(|extension| extension.trim_start_matches('.').to_string())
1252                    .collect(),
1253                binary: server.binary.clone().unwrap_or_default(),
1254                args: server.args.clone().unwrap_or_default(),
1255                root_markers: server
1256                    .root_markers
1257                    .clone()
1258                    .unwrap_or_else(|| vec![".git".to_string()]),
1259                env: server.env.clone().unwrap_or_default(),
1260                initialization_options: server.initialization_options.clone(),
1261                disabled: server.disabled.unwrap_or(false),
1262            })
1263            .collect();
1264    }
1265
1266    if !disabled.is_empty() {
1267        config.disabled_lsp = disabled;
1268    }
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Eq)]
1272struct ResolvedBashConfig {
1273    enabled: bool,
1274    rewrite: bool,
1275    compress: bool,
1276    background: bool,
1277    subagent_background: bool,
1278    long_running_reminder_enabled: Option<bool>,
1279    long_running_reminder_interval_ms: Option<u64>,
1280    foreground_wait_window_ms: u64,
1281}
1282
1283fn resolve_bash_fields(raw: &RawAftConfig, config: &mut Config) {
1284    let bash = resolve_bash_config(raw);
1285    // Resolve enabled and subagent_background, then discard them, because Rust
1286    // does not use those two bash settings at runtime yet. Doing the resolution
1287    // here keeps user config overriding project config in the same way as the
1288    // bash fields below, so a later response can expose the values without
1289    // changing how config files are interpreted.
1290    let _registration_only = (bash.enabled, bash.subagent_background);
1291    config.experimental_bash_rewrite = bash.rewrite;
1292    config.experimental_bash_compress = bash.compress;
1293    config.experimental_bash_background = bash.background;
1294    config.foreground_wait_window_ms = bash.foreground_wait_window_ms;
1295    if let Some(value) = bash.long_running_reminder_enabled {
1296        config.bash_long_running_reminder_enabled = value;
1297    }
1298    if let Some(value) = bash.long_running_reminder_interval_ms {
1299        config.bash_long_running_reminder_interval_ms = value;
1300    }
1301}
1302
1303fn resolve_bash_config(raw: &RawAftConfig) -> ResolvedBashConfig {
1304    let top = raw.bash.as_ref();
1305    let legacy = raw
1306        .experimental
1307        .as_ref()
1308        .and_then(|experimental| experimental.bash.as_ref());
1309    let surface = raw.tool_surface.unwrap_or(RawToolSurface::Recommended);
1310    let surface_default_enabled = surface != RawToolSurface::Minimal;
1311
1312    let top_features = match top {
1313        Some(RawBash::Features(features)) => Some(features),
1314        _ => None,
1315    };
1316    let reminder_enabled = top_features
1317        .and_then(|features| features.long_running_reminder_enabled)
1318        .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_enabled));
1319    let reminder_interval = top_features
1320        .and_then(|features| features.long_running_reminder_interval_ms)
1321        .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_interval_ms));
1322    let top_subagent_background = top_features
1323        .and_then(|features| features.subagent_background)
1324        .unwrap_or(false);
1325    let raw_foreground_wait = top_features.and_then(|features| features.foreground_wait_window_ms);
1326    let foreground_wait_window_ms = raw_foreground_wait
1327        .unwrap_or(FOREGROUND_WAIT_WINDOW_DEFAULT_MS)
1328        .max(FOREGROUND_WAIT_WINDOW_MIN_MS);
1329
1330    let base = ResolvedBashConfig {
1331        enabled: false,
1332        rewrite: false,
1333        compress: false,
1334        background: false,
1335        subagent_background: false,
1336        long_running_reminder_enabled: reminder_enabled,
1337        long_running_reminder_interval_ms: reminder_interval,
1338        foreground_wait_window_ms,
1339    };
1340
1341    match top {
1342        Some(RawBash::Bool(false)) => base,
1343        Some(RawBash::Bool(true)) => ResolvedBashConfig {
1344            enabled: true,
1345            rewrite: true,
1346            compress: true,
1347            background: true,
1348            ..base
1349        },
1350        Some(RawBash::Features(features)) => ResolvedBashConfig {
1351            enabled: true,
1352            rewrite: features.rewrite.unwrap_or(true),
1353            compress: features.compress.unwrap_or(true),
1354            background: features.background.unwrap_or(true),
1355            subagent_background: top_subagent_background,
1356            ..base
1357        },
1358        None => {
1359            if legacy.is_some_and(RawExperimentalBash::has_legacy_feature_flag) {
1360                let legacy = legacy.cloned().unwrap_or_default();
1361                let rewrite = legacy.rewrite == Some(true);
1362                let compress = legacy.compress == Some(true);
1363                let background = legacy.background == Some(true);
1364                return ResolvedBashConfig {
1365                    enabled: rewrite || compress || background,
1366                    rewrite,
1367                    compress,
1368                    background,
1369                    ..base
1370                };
1371            }
1372
1373            ResolvedBashConfig {
1374                enabled: surface_default_enabled,
1375                rewrite: surface_default_enabled,
1376                compress: surface_default_enabled,
1377                background: surface_default_enabled,
1378                ..base
1379            }
1380        }
1381    }
1382}
1383
1384fn deserialize_opt_trimmed_non_empty_string<'de, D>(
1385    deserializer: D,
1386) -> Result<Option<String>, D::Error>
1387where
1388    D: Deserializer<'de>,
1389{
1390    let value = Option::<String>::deserialize(deserializer)?;
1391    value
1392        .map(|value| {
1393            let trimmed = value.trim().to_string();
1394            if trimmed.is_empty() {
1395                Err(de::Error::custom("must be a non-empty string"))
1396            } else {
1397                Ok(trimmed)
1398            }
1399        })
1400        .transpose()
1401}
1402
1403fn deserialize_opt_trimmed_non_empty_string_vec<'de, D>(
1404    deserializer: D,
1405) -> Result<Option<Vec<String>>, D::Error>
1406where
1407    D: Deserializer<'de>,
1408{
1409    let value = Option::<Vec<String>>::deserialize(deserializer)?;
1410    value
1411        .map(|values| {
1412            values
1413                .into_iter()
1414                .map(|value| {
1415                    let trimmed = value.trim().to_string();
1416                    if trimmed.is_empty() {
1417                        Err(de::Error::custom("array entries must be non-empty strings"))
1418                    } else {
1419                        Ok(trimmed)
1420                    }
1421                })
1422                .collect()
1423        })
1424        .transpose()
1425}
1426
1427fn deserialize_opt_lsp_extensions<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
1428where
1429    D: Deserializer<'de>,
1430{
1431    let value = Option::<Vec<String>>::deserialize(deserializer)?;
1432    value
1433        .map(|values| {
1434            if values.is_empty() {
1435                return Err(de::Error::custom(
1436                    "extensions must contain at least one entry",
1437                ));
1438            }
1439            values
1440                .into_iter()
1441                .map(|value| {
1442                    let trimmed = value.trim().to_string();
1443                    if trimmed.is_empty() || trimmed.trim_start_matches('.').is_empty() {
1444                        Err(de::Error::custom(
1445                            "extension must include characters other than leading dots",
1446                        ))
1447                    } else {
1448                        Ok(trimmed)
1449                    }
1450                })
1451                .collect()
1452        })
1453        .transpose()
1454}
1455
1456fn deserialize_opt_lsp_servers<'de, D>(
1457    deserializer: D,
1458) -> Result<Option<BTreeMap<String, RawLspServerEntry>>, D::Error>
1459where
1460    D: Deserializer<'de>,
1461{
1462    let value = Option::<BTreeMap<String, RawLspServerEntry>>::deserialize(deserializer)?;
1463    value
1464        .map(|entries| {
1465            entries
1466                .into_iter()
1467                .map(|(key, value)| {
1468                    let trimmed = key.trim().to_string();
1469                    if trimmed.is_empty() {
1470                        Err(de::Error::custom(
1471                            "lsp.servers keys must be non-empty strings",
1472                        ))
1473                    } else {
1474                        Ok((trimmed, value))
1475                    }
1476                })
1477                .collect()
1478        })
1479        .transpose()
1480}
1481
1482fn deserialize_opt_versions_map<'de, D>(
1483    deserializer: D,
1484) -> Result<Option<HashMap<String, String>>, D::Error>
1485where
1486    D: Deserializer<'de>,
1487{
1488    let value = Option::<HashMap<String, String>>::deserialize(deserializer)?;
1489    value
1490        .map(|entries| {
1491            entries
1492                .into_iter()
1493                .map(|(key, value)| {
1494                    let trimmed_key = key.trim().to_string();
1495                    let trimmed_value = value.trim().to_string();
1496                    if trimmed_key.is_empty() || trimmed_value.is_empty() {
1497                        Err(de::Error::custom(
1498                            "lsp.versions keys and values must be non-empty strings",
1499                        ))
1500                    } else {
1501                        Ok((trimmed_key, trimmed_value))
1502                    }
1503                })
1504                .collect()
1505        })
1506        .transpose()
1507}
1508
1509fn deserialize_opt_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1510where
1511    D: Deserializer<'de>,
1512{
1513    let value = Option::<u64>::deserialize(deserializer)?;
1514    value
1515        .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1516        .transpose()
1517}
1518
1519fn deserialize_opt_positive_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1520where
1521    D: Deserializer<'de>,
1522{
1523    let value = Option::<u64>::deserialize(deserializer)?;
1524    match value {
1525        Some(0) => Err(de::Error::custom("must be a positive integer")),
1526        other => Ok(other),
1527    }
1528}
1529
1530fn deserialize_opt_positive_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1531where
1532    D: Deserializer<'de>,
1533{
1534    let value = deserialize_opt_positive_u64(deserializer)?;
1535    value
1536        .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1537        .transpose()
1538}
1539
1540fn deserialize_opt_timeout_secs<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1541where
1542    D: Deserializer<'de>,
1543{
1544    let value = Option::<u64>::deserialize(deserializer)?;
1545    match value {
1546        Some(value) if !(1..=600).contains(&value) => {
1547            Err(de::Error::custom("timeout must be in 1..=600 seconds"))
1548        }
1549        Some(value) => u32::try_from(value)
1550            .map(Some)
1551            .map_err(|_| de::Error::custom("timeout is too large")),
1552        None => Ok(None),
1553    }
1554}
1555
1556fn deserialize_opt_bridge_request_timeout_ms<'de, D>(
1557    deserializer: D,
1558) -> Result<Option<u64>, D::Error>
1559where
1560    D: Deserializer<'de>,
1561{
1562    let value = Option::<u64>::deserialize(deserializer)?;
1563    match value {
1564        Some(value) if value < 1_000 => Err(de::Error::custom(
1565            "bridge.request_timeout_ms must be at least 1000",
1566        )),
1567        other => Ok(other),
1568    }
1569}
1570
1571fn deserialize_opt_nonnegative_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
1572where
1573    D: Deserializer<'de>,
1574{
1575    let value = Option::<f64>::deserialize(deserializer)?;
1576    match value {
1577        Some(value) if value < 0.0 => Err(de::Error::custom("must be non-negative")),
1578        other => Ok(other),
1579    }
1580}
1581
1582fn deserialize_opt_drill_down_items<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1583where
1584    D: Deserializer<'de>,
1585{
1586    let value = Option::<u64>::deserialize(deserializer)?;
1587    match value {
1588        Some(value) if value == 0 || value > 100 => {
1589            Err(de::Error::custom("max_drill_down_items must be in 1..=100"))
1590        }
1591        Some(value) => usize::try_from(value)
1592            .map(Some)
1593            .map_err(|_| de::Error::custom("max_drill_down_items is too large")),
1594        None => Ok(None),
1595    }
1596}
1597
1598#[cfg(test)]
1599mod tests {
1600    use super::*;
1601
1602    fn tier(tier: &str, doc: &str) -> ConfigTier {
1603        ConfigTier {
1604            tier: tier.to_string(),
1605            source: format!("/tmp/{tier}/aft.jsonc"),
1606            doc: doc.to_string(),
1607        }
1608    }
1609
1610    fn drop_keys(result: &ResolveResult) -> Vec<String> {
1611        result
1612            .dropped
1613            .iter()
1614            .map(|dropped| dropped.key.clone())
1615            .collect()
1616    }
1617
1618    /// Security invariant (Oracle drift decision): nested objects are non-strict
1619    /// (match TS z.object — unknown nested keys are stripped, object survives),
1620    /// but the TOP-LEVEL RawAftConfig stays strict. A privileged process-state
1621    /// field is top-level, so a project tier trying to smuggle one still hits the
1622    /// strict top-level → that tier fails full parse, and partial-parse drops the
1623    /// unknown key. It can NEVER reach Config.
1624    #[test]
1625    fn nested_unknown_keys_are_stripped_but_top_level_privileged_keys_cannot_smuggle() {
1626        // Nested unknown key: stripped, object survives — parity with TS (golden
1627        // `bash_unknown_nested_key`). `bash: { unknown_key }` resolves like
1628        // `bash: {}` → object form → bash ENABLED (object presence beats the
1629        // minimal surface default). The point: the unknown key did not fail the
1630        // parse — the object survived and resolved.
1631        let nested = resolve_config(&[tier(
1632            "user",
1633            r#"{ "tool_surface": "minimal", "bash": { "unknown_key": true } }"#,
1634        )]);
1635        assert!(nested.config.experimental_bash_rewrite);
1636        assert!(nested.config.experimental_bash_compress);
1637        assert!(nested.config.experimental_bash_background);
1638
1639        // Top-level privileged process-state field (storage_dir) from a PROJECT
1640        // tier: not in RawAftConfig → full parse fails → partial-parse drops it.
1641        // It must never appear in Config (Config keeps its default storage_dir).
1642        let smuggle = resolve_config(&[
1643            tier("user", r#"{ "search_index": true }"#),
1644            tier(
1645                "project",
1646                r#"{ "storage_dir": "/tmp/evil", "bash_permissions": true, "search_index": false }"#,
1647            ),
1648        ]);
1649        // The valid project key (search_index) still applies via partial-parse...
1650        assert!(!smuggle.config.search_index);
1651        // ...but the smuggled process-state fields never reach Config.
1652        assert!(smuggle.config.storage_dir.is_none());
1653        assert!(!smuggle.config.bash_permissions);
1654    }
1655
1656    #[test]
1657    fn config_resolve_empty_tiers_applies_bash_surface_default() {
1658        // No config file ⇒ empty config object, which still flows through the
1659        // resolver and picks up the bash surface default (recommended ⇒ on),
1660        // matching the TS pipeline (golden fixture `empty`). NOT Config::default()
1661        // — that would leave bash off, diverging from TS.
1662        let result = resolve_config(&[]);
1663        let default_config = Config::default();
1664
1665        assert!(result.dropped.is_empty());
1666        // Non-bash fields stay at runtime default.
1667        assert_eq!(result.config.format_on_edit, default_config.format_on_edit);
1668        assert_eq!(result.config.search_index, default_config.search_index);
1669        assert_eq!(
1670            result.config.semantic_search,
1671            default_config.semantic_search
1672        );
1673        assert_eq!(result.config.semantic, default_config.semantic);
1674        assert_eq!(
1675            result.config.inspect.enabled,
1676            default_config.inspect.enabled
1677        );
1678        assert_eq!(result.config.lsp_servers.len(), 0);
1679        // Bash surface default: recommended ⇒ rewrite/compress/background all on.
1680        assert!(result.config.experimental_bash_rewrite);
1681        assert!(result.config.experimental_bash_compress);
1682        assert!(result.config.experimental_bash_background);
1683    }
1684
1685    #[test]
1686    fn config_resolve_user_only_config_applies_fields() {
1687        let result = resolve_config(&[tier(
1688            "user",
1689            r#"{
1690              "$schema": "https://example.test/aft.schema.json",
1691              "format_on_edit": false,
1692              "formatter_timeout_secs": 42,
1693              "type_checker_timeout_secs": 43,
1694              "validate_on_edit": "full",
1695              "formatter": { "rust": "rustfmt", "typescript": "prettier" },
1696              "checker": { "rust": "cargo", "typescript": "tsc" },
1697              "restrict_to_project_root": true,
1698              "search_index": true,
1699              "semantic_search": true,
1700              "callgraph_store": false,
1701              "callgraph_chunk_size": 17,
1702              "url_fetch_allow_private": true,
1703              "semantic": {
1704                "backend": "openai_compatible",
1705                "model": "  user-model  ",
1706                "base_url": "https://semantic.example.test",
1707                "api_key_env": "AFT_API_KEY",
1708                "timeout_ms": 12345,
1709                "query_timeout_ms": 2345,
1710                "max_batch_size": 12,
1711                "max_files": 3456
1712              },
1713              "inspect": { "enabled": false },
1714              "experimental": { "lsp_ty": true },
1715              "lsp": {
1716                "servers": {
1717                  "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
1718                },
1719                "disabled": ["Python"],
1720                "python": "pyright"
1721              },
1722              "bash": { "rewrite": false, "compress": true, "background": false,
1723                        "long_running_reminder_enabled": false,
1724                        "long_running_reminder_interval_ms": 123000 }
1725            }"#,
1726        )]);
1727
1728        assert!(result.dropped.is_empty());
1729        assert!(!result.config.format_on_edit);
1730        assert_eq!(result.config.formatter_timeout_secs, 42);
1731        assert_eq!(result.config.type_checker_timeout_secs, 43);
1732        assert_eq!(result.config.validate_on_edit.as_deref(), Some("full"));
1733        assert_eq!(
1734            result.config.formatter.get("rust").map(String::as_str),
1735            Some("rustfmt")
1736        );
1737        assert_eq!(
1738            result.config.checker.get("typescript").map(String::as_str),
1739            Some("tsc")
1740        );
1741        assert!(result.config.restrict_to_project_root);
1742        assert!(result.config.search_index);
1743        assert!(result.config.semantic_search);
1744        assert!(!result.config.callgraph_store);
1745        assert_eq!(result.config.callgraph_chunk_size, 17);
1746        assert!(result.config.url_fetch_allow_private);
1747        assert_eq!(
1748            result.config.semantic.backend,
1749            SemanticBackend::OpenAiCompatible
1750        );
1751        assert_eq!(result.config.semantic.model, "user-model");
1752        assert_eq!(
1753            result.config.semantic.base_url.as_deref(),
1754            Some("https://semantic.example.test")
1755        );
1756        assert_eq!(
1757            result.config.semantic.api_key_env.as_deref(),
1758            Some("AFT_API_KEY")
1759        );
1760        assert_eq!(result.config.semantic.timeout_ms, 12345);
1761        assert_eq!(result.config.semantic.query_timeout_ms, 2345);
1762        assert_eq!(result.config.semantic.max_batch_size, 12);
1763        assert_eq!(result.config.semantic.max_files, 3456);
1764        assert!(!result.config.inspect.enabled);
1765        assert!(!result.config.experimental_lsp_ty);
1766        assert!(result.config.disabled_lsp.contains("ty"));
1767        assert_eq!(result.config.lsp_servers.len(), 1);
1768        assert_eq!(result.config.lsp_servers[0].id, "rust");
1769        assert_eq!(
1770            result.config.lsp_servers[0].extensions,
1771            vec!["rs".to_string()]
1772        );
1773        assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
1774        assert_eq!(result.config.lsp_servers[0].args, Vec::<String>::new());
1775        assert_eq!(
1776            result.config.lsp_servers[0].root_markers,
1777            vec![".git".to_string()]
1778        );
1779        assert!(!result.config.experimental_bash_rewrite);
1780        assert!(result.config.experimental_bash_compress);
1781        assert!(!result.config.experimental_bash_background);
1782        assert!(!result.config.bash_long_running_reminder_enabled);
1783        assert_eq!(result.config.bash_long_running_reminder_interval_ms, 123000);
1784    }
1785
1786    #[test]
1787    fn edit_mode_resolves_at_user_and_project_tiers_with_project_precedence() {
1788        let hashline = resolve_config(&[
1789            tier("user", r#"{"edit_mode":"default"}"#),
1790            tier("project", r#"{"edit_mode":"hashline"}"#),
1791        ]);
1792        assert!(hashline.config.hashline_enabled);
1793        assert!(hashline.dropped.is_empty());
1794
1795        let default = resolve_config(&[
1796            tier("user", r#"{"edit_mode":"hashline"}"#),
1797            tier("project", r#"{"edit_mode":"default"}"#),
1798        ]);
1799        assert!(!default.config.hashline_enabled);
1800        assert!(default.dropped.is_empty());
1801    }
1802
1803    #[test]
1804    fn unknown_edit_mode_warns_and_falls_back_without_dropping_valid_keys() {
1805        let result = resolve_config(&[tier(
1806            "project",
1807            r#"{"edit_mode":"future","format_on_edit":true}"#,
1808        )]);
1809
1810        assert!(!result.config.hashline_enabled);
1811        assert!(result.config.format_on_edit);
1812        assert_eq!(result.warnings.len(), 1);
1813        assert_eq!(result.warnings[0].code, "invalid_edit_mode");
1814        assert_eq!(result.warnings[0].key, "edit_mode");
1815        assert_eq!(result.warnings[0].tier, "project");
1816        assert_eq!(result.warnings[0].value, "future");
1817    }
1818
1819    #[test]
1820    fn semantic_query_timeout_clamps_to_interactive_budget_range() {
1821        let below_min =
1822            resolve_config(&[tier("user", r#"{ "semantic": { "query_timeout_ms": 1 } }"#)]);
1823        assert_eq!(
1824            below_min.config.semantic.query_timeout_ms,
1825            MIN_SEMANTIC_QUERY_TIMEOUT_MS
1826        );
1827
1828        let above_max = resolve_config(&[tier(
1829            "user",
1830            r#"{ "semantic": { "query_timeout_ms": 50000 } }"#,
1831        )]);
1832        assert_eq!(
1833            above_max.config.semantic.query_timeout_ms,
1834            MAX_SEMANTIC_QUERY_TIMEOUT_MS
1835        );
1836    }
1837
1838    #[test]
1839    fn config_resolve_project_allowed_search_index_wins() {
1840        let result = resolve_config(&[
1841            tier("user", r#"{ "search_index": false }"#),
1842            tier("project", r#"{ "search_index": true }"#),
1843        ]);
1844
1845        assert!(result.config.search_index);
1846        assert!(result.dropped.is_empty());
1847    }
1848
1849    #[test]
1850    fn project_sandbox_can_add_read_denies_but_not_enable_or_add_writes() {
1851        let result = resolve_config(&[
1852            tier(
1853                "user",
1854                r#"{
1855                  "sandbox": {
1856                    "enabled": true,
1857                    "write_allow": ["/user/write"],
1858                    "read_deny": ["/user/secret"]
1859                  }
1860                }"#,
1861            ),
1862            tier(
1863                "project",
1864                r#"{
1865                  "sandbox": {
1866                    "enabled": false,
1867                    "write_allow": ["/project/write"],
1868                    "read_deny": ["/project/secret", "/user/secret"]
1869                  }
1870                }"#,
1871            ),
1872        ]);
1873
1874        assert!(result.config.sandbox.enabled);
1875        assert_eq!(
1876            result.config.sandbox.write_allow,
1877            vec![PathBuf::from("/user/write")]
1878        );
1879        assert_eq!(
1880            result.config.sandbox.read_deny,
1881            vec![
1882                PathBuf::from("/user/secret"),
1883                PathBuf::from("/project/secret")
1884            ]
1885        );
1886        assert_eq!(
1887            drop_keys(&result),
1888            vec![
1889                "sandbox.enabled".to_string(),
1890                "sandbox.write_allow".to_string()
1891            ]
1892        );
1893    }
1894
1895    #[test]
1896    fn project_can_enable_sandbox_but_never_disable_it() {
1897        // Hardening is one-way: a repo may opt itself into kernel confinement.
1898        let opt_in = resolve_config(&[
1899            tier("user", r#"{}"#),
1900            tier("project", r#"{ "sandbox": { "enabled": true } }"#),
1901        ]);
1902        assert!(opt_in.config.sandbox.enabled);
1903        assert!(
1904            !drop_keys(&opt_in).contains(&"sandbox.enabled".to_string()),
1905            "project enabled:true is an accepted opt-in, not a dropped key"
1906        );
1907
1908        // The weakening direction stays user-only: project false cannot win.
1909        let opt_out = resolve_config(&[
1910            tier("user", r#"{ "sandbox": { "enabled": true } }"#),
1911            tier("project", r#"{ "sandbox": { "enabled": false } }"#),
1912        ]);
1913        assert!(opt_out.config.sandbox.enabled);
1914        assert!(drop_keys(&opt_out).contains(&"sandbox.enabled".to_string()));
1915    }
1916
1917    #[test]
1918    fn config_resolve_project_user_only_keys_are_dropped_and_user_values_win() {
1919        let result = resolve_config(&[
1920            tier(
1921                "user",
1922                r#"{
1923                  "restrict_to_project_root": true,
1924                  "url_fetch_allow_private": true,
1925                  "formatter_timeout_secs": 11,
1926                  "type_checker_timeout_secs": 33,
1927                  "auto_update": true,
1928                  "bridge": { "request_timeout_ms": 3000, "hang_threshold": 3 },
1929                  "semantic": {
1930                    "backend": "openai_compatible",
1931                    "base_url": "https://user.example.test",
1932                    "api_key_env": "USER_KEY",
1933                    "model": "user-model",
1934                    "query_timeout_ms": 900
1935                  },
1936                  "lsp": {
1937                    "servers": {
1938                      "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
1939                    },
1940                    "disabled": ["user-disabled"],
1941                    "versions": { "typescript-language-server": "1.0.0" },
1942                    "auto_install": true,
1943                    "grace_days": 7
1944                  }
1945                }"#,
1946            ),
1947            tier(
1948                "project",
1949                r#"{
1950                  "restrict_to_project_root": false,
1951                  "url_fetch_allow_private": false,
1952                  "formatter_timeout_secs": 22,
1953                  "type_checker_timeout_secs": 44,
1954                  "auto_update": false,
1955                  "bridge": { "request_timeout_ms": 4000, "hang_threshold": 4 },
1956                  "semantic": {
1957                    "backend": "ollama",
1958                    "base_url": "https://project.example.test",
1959                    "api_key_env": "PROJECT_KEY",
1960                    "model": "project-model",
1961                    "timeout_ms": 2222,
1962                    "query_timeout_ms": 2222
1963                  },
1964                  "lsp": {
1965                    "servers": {
1966                      "rust": { "extensions": [".evil"], "binary": "evil-lsp" }
1967                    },
1968                    "disabled": ["project-disabled"],
1969                    "versions": { "evil-lsp": "9.9.9" },
1970                    "auto_install": false,
1971                    "grace_days": 1,
1972                    "python": "ty"
1973                  }
1974                }"#,
1975            ),
1976        ]);
1977
1978        assert!(result.config.restrict_to_project_root);
1979        assert!(result.config.url_fetch_allow_private);
1980        assert_eq!(result.config.formatter_timeout_secs, 11);
1981        assert_eq!(result.config.type_checker_timeout_secs, 33);
1982        assert_eq!(
1983            result.config.semantic.backend,
1984            SemanticBackend::OpenAiCompatible
1985        );
1986        assert_eq!(
1987            result.config.semantic.base_url.as_deref(),
1988            Some("https://user.example.test")
1989        );
1990        assert_eq!(
1991            result.config.semantic.api_key_env.as_deref(),
1992            Some("USER_KEY")
1993        );
1994        assert_eq!(result.config.semantic.model, "project-model");
1995        assert_eq!(result.config.semantic.timeout_ms, 2222);
1996        assert_eq!(result.config.semantic.query_timeout_ms, 900);
1997        assert_eq!(result.config.lsp_servers.len(), 1);
1998        assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
1999        assert!(result.config.disabled_lsp.contains("user-disabled"));
2000        assert!(!result.config.disabled_lsp.contains("project-disabled"));
2001        assert!(result.config.disabled_lsp.contains("python"));
2002        assert!(result.config.experimental_lsp_ty);
2003
2004        let keys = drop_keys(&result);
2005        let expected = [
2006            "restrict_to_project_root",
2007            "url_fetch_allow_private",
2008            "formatter_timeout_secs",
2009            "type_checker_timeout_secs",
2010            "auto_update",
2011            "bridge",
2012            "semantic.backend",
2013            "semantic.base_url",
2014            "semantic.api_key_env",
2015            "semantic.query_timeout_ms",
2016            "lsp.servers",
2017            "lsp.versions",
2018            "lsp.auto_install",
2019            "lsp.grace_days",
2020            "lsp.disabled",
2021        ];
2022        for key in expected {
2023            assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
2024        }
2025        assert_eq!(keys.len(), expected.len());
2026        assert!(result
2027            .dropped
2028            .iter()
2029            .all(|dropped| dropped.tier == "project"));
2030    }
2031
2032    #[test]
2033    fn config_resolve_bash_ladder_and_merge_parity() {
2034        let true_result = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
2035        assert!(true_result.config.experimental_bash_rewrite);
2036        assert!(true_result.config.experimental_bash_compress);
2037        assert!(true_result.config.experimental_bash_background);
2038
2039        let false_result = resolve_config(&[tier("user", r#"{ "bash": false }"#)]);
2040        assert!(!false_result.config.experimental_bash_rewrite);
2041        assert!(!false_result.config.experimental_bash_compress);
2042        assert!(!false_result.config.experimental_bash_background);
2043
2044        let object_default_result = resolve_config(&[tier("user", r#"{ "bash": {} }"#)]);
2045        assert!(object_default_result.config.experimental_bash_rewrite);
2046        assert!(object_default_result.config.experimental_bash_compress);
2047        assert!(object_default_result.config.experimental_bash_background);
2048
2049        let object_partial_result =
2050            resolve_config(&[tier("user", r#"{ "bash": { "compress": false } }"#)]);
2051        assert!(object_partial_result.config.experimental_bash_rewrite);
2052        assert!(!object_partial_result.config.experimental_bash_compress);
2053        assert!(object_partial_result.config.experimental_bash_background);
2054
2055        let legacy_result = resolve_config(&[tier(
2056            "user",
2057            r#"{ "experimental": { "bash": { "rewrite": true } } }"#,
2058        )]);
2059        assert!(legacy_result.config.experimental_bash_rewrite);
2060        assert!(!legacy_result.config.experimental_bash_compress);
2061        assert!(!legacy_result.config.experimental_bash_background);
2062
2063        let surface_default_result = resolve_config(&[tier("user", r#"{}"#)]);
2064        assert!(surface_default_result.config.experimental_bash_rewrite);
2065        assert!(surface_default_result.config.experimental_bash_compress);
2066        assert!(surface_default_result.config.experimental_bash_background);
2067
2068        let minimal_surface_result =
2069            resolve_config(&[tier("user", r#"{ "tool_surface": "minimal" }"#)]);
2070        assert!(!minimal_surface_result.config.experimental_bash_rewrite);
2071        assert!(!minimal_surface_result.config.experimental_bash_compress);
2072        assert!(!minimal_surface_result.config.experimental_bash_background);
2073
2074        let merged_result = resolve_config(&[
2075            tier("user", r#"{ "bash": true }"#),
2076            tier("project", r#"{ "bash": { "compress": false } }"#),
2077        ]);
2078        assert!(merged_result.config.experimental_bash_rewrite);
2079        assert!(!merged_result.config.experimental_bash_compress);
2080        assert!(merged_result.config.experimental_bash_background);
2081
2082        let false_then_object_result = resolve_config(&[
2083            tier("user", r#"{ "bash": false }"#),
2084            tier("project", r#"{ "bash": { "compress": true } }"#),
2085        ]);
2086        assert!(!false_then_object_result.config.experimental_bash_rewrite);
2087        assert!(false_then_object_result.config.experimental_bash_compress);
2088        assert!(!false_then_object_result.config.experimental_bash_background);
2089    }
2090
2091    #[test]
2092    fn config_resolve_bash_foreground_wait_clamps_to_floor() {
2093        let Some(raw) = parse_tier(&tier(
2094            "user",
2095            r#"{ "bash": { "foreground_wait_window_ms": 1, "subagent_background": true } }"#,
2096        )) else {
2097            panic!("test tier should parse");
2098        };
2099        let bash = resolve_bash_config(&raw);
2100
2101        assert_eq!(
2102            bash.foreground_wait_window_ms,
2103            FOREGROUND_WAIT_WINDOW_MIN_MS
2104        );
2105        assert!(bash.subagent_background);
2106
2107        let result = resolve_config(&[tier(
2108            "user",
2109            r#"{ "bash": { "foreground_wait_window_ms": 1 } }"#,
2110        )]);
2111        assert_eq!(
2112            result.config.foreground_wait_window_ms,
2113            FOREGROUND_WAIT_WINDOW_MIN_MS
2114        );
2115
2116        // Unset → the default wait-window (matches the plugin's
2117        // FOREGROUND_WAIT_WINDOW_DEFAULT_MS = 15_000). This locks the default,
2118        // not just the floor, so a future edit can't silently shorten the
2119        // server-side promotion window once the plugin orchestrates through Rust.
2120        let defaulted = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
2121        assert_eq!(
2122            defaulted.config.foreground_wait_window_ms,
2123            FOREGROUND_WAIT_WINDOW_DEFAULT_MS
2124        );
2125        assert_eq!(FOREGROUND_WAIT_WINDOW_DEFAULT_MS, 15_000);
2126    }
2127
2128    #[test]
2129    fn config_resolve_partial_parse_drops_invalid_section_and_keeps_valid_sections() {
2130        let result = resolve_config(&[tier(
2131            "user",
2132            r#"{
2133              "semantic": { "timeout_ms": 0 },
2134              "search_index": true,
2135              "format_on_edit": false
2136            }"#,
2137        )]);
2138
2139        assert!(result.config.search_index);
2140        assert!(!result.config.format_on_edit);
2141        assert_eq!(result.config.semantic, SemanticBackendConfig::default());
2142        assert!(result.dropped.is_empty());
2143    }
2144
2145    #[test]
2146    fn config_resolve_unknown_top_level_key_is_dropped_but_rest_survives() {
2147        let result = resolve_config(&[tier(
2148            "user",
2149            r#"{ "not_a_real_key": true, "search_index": true }"#,
2150        )]);
2151
2152        assert!(result.config.search_index);
2153        assert!(result.dropped.is_empty());
2154    }
2155
2156    #[test]
2157    fn resolve_config_onto_resets_core_fields_no_cross_bind_inheritance() {
2158        // Cross-bind escalation regression: a first (trusted) bind sets a
2159        // capability field; a second bind that omits it must NOT inherit it.
2160        // This is the configure-path entry (seeded from a prior config), where
2161        // reset-onto-default — unlike the old overlay — makes the resolved core
2162        // config a pure function of the CURRENT bind's tiers.
2163        let mut config = Config::default();
2164
2165        // Bind 1 (trusted user tier) sets url_fetch_allow_private + a custom LSP
2166        // server + restrict_to_project_root.
2167        let dropped1 = resolve_config_onto(
2168            &[tier(
2169                "user",
2170                r#"{
2171                  "url_fetch_allow_private": true,
2172                  "restrict_to_project_root": true,
2173                  "lsp": { "servers": { "rust": { "extensions": [".rs"], "binary": "rust-analyzer" } } }
2174                }"#,
2175            )],
2176            &mut config,
2177        );
2178        assert!(dropped1.is_empty());
2179        assert!(config.url_fetch_allow_private);
2180        assert!(config.restrict_to_project_root);
2181        assert_eq!(config.lsp_servers.len(), 1);
2182
2183        // Bind 2 omits all three. With reset semantics they return to DEFAULT —
2184        // the second bind cannot inherit the first bind's capabilities.
2185        let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2186        assert!(
2187            !config.url_fetch_allow_private,
2188            "url_fetch_allow_private must reset to default, not inherit prior bind"
2189        );
2190        assert!(
2191            !config.restrict_to_project_root,
2192            "restrict_to_project_root must reset to default"
2193        );
2194        assert!(
2195            config.lsp_servers.is_empty(),
2196            "lsp_servers must reset to default, not inherit prior bind's custom server"
2197        );
2198        assert!(config.search_index, "this bind's own field still applies");
2199    }
2200
2201    #[test]
2202    fn resolve_config_onto_empty_tiers_resets_to_default() {
2203        // The empty-tier path must still reset (it routes through the same
2204        // always-run resolution in handle_configure). A bind with no tiers after
2205        // a privileged bind must drop the privileged config.
2206        let mut config = Config::default();
2207        let _ = resolve_config_onto(
2208            &[tier("user", r#"{ "url_fetch_allow_private": true }"#)],
2209            &mut config,
2210        );
2211        assert!(config.url_fetch_allow_private);
2212
2213        let _ = resolve_config_onto(&[], &mut config);
2214        assert!(
2215            !config.url_fetch_allow_private,
2216            "empty-tier bind must reset core config to default"
2217        );
2218    }
2219
2220    #[test]
2221    fn resolve_config_onto_preserves_process_state_fields() {
2222        // Process-state fields (not part of RawAftConfig) are carried across the
2223        // reset so plugin-mode behavior is unchanged (the plugin re-sends them
2224        // via flat configure params right after this call).
2225        let mut config = Config {
2226            storage_dir: Some(std::path::PathBuf::from("/tmp/aft-store")),
2227            lsp_paths_extra: vec![std::path::PathBuf::from("/tmp/lsp-bin")],
2228            bash_permissions: true,
2229            project_root: Some(std::path::PathBuf::from("/tmp/proj")),
2230            ..Default::default()
2231        };
2232
2233        let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2234
2235        assert_eq!(
2236            config.storage_dir,
2237            Some(std::path::PathBuf::from("/tmp/aft-store"))
2238        );
2239        assert_eq!(
2240            config.lsp_paths_extra,
2241            vec![std::path::PathBuf::from("/tmp/lsp-bin")]
2242        );
2243        assert!(config.bash_permissions);
2244        assert_eq!(
2245            config.project_root,
2246            Some(std::path::PathBuf::from("/tmp/proj"))
2247        );
2248        assert!(config.search_index);
2249    }
2250
2251    #[test]
2252    fn config_resolve_jsonc_comments_and_trailing_commas_parse() {
2253        let result = resolve_config(&[tier(
2254            "user",
2255            r#"{
2256              // line comment
2257              "search_index": true,
2258              "formatter": {
2259                "rust": "rustfmt", /* block comment */
2260              },
2261            }"#,
2262        )]);
2263
2264        assert!(result.config.search_index);
2265        assert_eq!(
2266            result.config.formatter.get("rust").map(String::as_str),
2267            Some("rustfmt")
2268        );
2269    }
2270}