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    expand_index_root_path, normalize_git_co_author, BackupConfig, Config, GhShimConfig, GitConfig,
17    IndexConfig, IndexKind, IndexRootConfig, InspectConfig, SandboxConfig, SemanticBackend,
18    SemanticBackendConfig, UserServerDef, WorktreeConfig, DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
19    MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS,
20    MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS, MIN_SEMANTIC_QUERY_TIMEOUT_MS,
21};
22use crate::harness::Harness;
23use crate::jsonc::strip_jsonc;
24
25const FOREGROUND_WAIT_WINDOW_DEFAULT_MS: u64 = 15_000;
26const FOREGROUND_WAIT_WINDOW_MIN_MS: u64 = 5_000;
27
28// Semantic budget clamps — restored from the deleted configure-time
29// parse_semantic_config so the tier-resolved Config matches the historical
30// clamping (zero-behavior-change relocation, not a new policy).
31const MAX_SEMANTIC_TIMEOUT_MS: u64 = 120_000;
32const MAX_SEMANTIC_BATCH_SIZE: usize = 1_024;
33
34const USER_ONLY_REASON: &str =
35    "security: this setting only honors user-level config and project values are ignored";
36const SEMANTIC_SECRET_REASON: &str =
37    "security: semantic backend credentials and endpoints must come from user-level config";
38const LSP_USER_ONLY_REASON: &str =
39    "security: LSP executable-origin and diagnostic-suppression settings must come from user-level config";
40
41/// One raw config document supplied by the host plugin.
42///
43/// `tier` is trusted process metadata stamped by the caller. The document body is
44/// never allowed to relabel itself.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ConfigTier {
47    pub tier: String,
48    pub source: String,
49    pub doc: String,
50}
51
52/// A project-tier key that was intentionally ignored at the user/project trust boundary.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DroppedKey {
55    pub key: String,
56    pub tier: String,
57    pub reason: String,
58}
59
60/// A non-fatal config issue reported back to the caller during configuration.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ConfigWarning {
63    pub code: &'static str,
64    pub key: &'static str,
65    pub tier: String,
66    pub value: String,
67    pub message: String,
68}
69
70/// Diagnostics produced while resetting an existing runtime config.
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct ResolveDiagnostics {
73    pub dropped: Vec<DroppedKey>,
74    pub warnings: Vec<ConfigWarning>,
75}
76
77/// Fully resolved core config plus trust-boundary diagnostics.
78#[derive(Debug, Clone)]
79pub struct ResolveResult {
80    pub config: Config,
81    pub dropped: Vec<DroppedKey>,
82    pub warnings: Vec<ConfigWarning>,
83}
84
85/// Strict raw shape for aft.jsonc. This mirrors the TypeScript Zod schema, not
86/// the flat runtime [`Config`]. Privileged process-state fields are deliberately
87/// absent and therefore rejected by `deny_unknown_fields`.
88#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
89#[serde(default, deny_unknown_fields)]
90pub struct RawAftConfig {
91    #[serde(rename = "$schema")]
92    pub schema: Option<String>,
93    /// Master switch read by the TypeScript plugins before they start AFT.
94    /// The resolver accepts and merges it for validation, but does not copy it
95    /// into `Config`; when this is false the plugin returns before launching the
96    /// Rust process.
97    pub enabled: Option<bool>,
98    pub edit_mode: Option<RawEditMode>,
99    pub format_on_edit: Option<bool>,
100    #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
101    pub formatter_timeout_secs: Option<u32>,
102    #[serde(deserialize_with = "deserialize_opt_timeout_secs")]
103    pub type_checker_timeout_secs: Option<u32>,
104    pub validate_on_edit: Option<RawValidateOnEdit>,
105    pub formatter: Option<HashMap<String, RawFormatter>>,
106    pub checker: Option<HashMap<String, RawChecker>>,
107    pub configure_warnings_delivery: Option<RawConfigureWarningsDelivery>,
108    pub hoist_builtin_tools: Option<bool>,
109    pub tool_surface: Option<RawToolSurface>,
110    pub disabled_tools: Option<Vec<String>>,
111    pub restrict_to_project_root: Option<bool>,
112    pub search_index: Option<bool>,
113    pub index: Option<RawIndex>,
114    pub semantic_search: Option<bool>,
115    pub callgraph_store: Option<bool>,
116    #[serde(deserialize_with = "deserialize_opt_usize")]
117    pub callgraph_chunk_size: Option<usize>,
118    pub inspect: Option<RawInspect>,
119    pub backup: Option<RawBackup>,
120    pub worktree: Option<RawWorktree>,
121    pub gh_shim: Option<RawGhShim>,
122    pub git: Option<RawGit>,
123    pub sandbox: Option<RawSandbox>,
124    pub bash: Option<RawBash>,
125    pub experimental: Option<RawExperimental>,
126    pub lsp: Option<RawLsp>,
127    pub url_fetch_allow_private: Option<bool>,
128    pub semantic: Option<RawSemantic>,
129    pub auto_update: Option<bool>,
130    pub bridge: Option<RawBridge>,
131    pub subc: Option<RawSubc>,
132    /// Raw per-harness objects stay opaque until the resolver knows the active
133    /// configure harness. Unknown harness names are intentionally ignored.
134    pub harnesses: Option<BTreeMap<String, Value>>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum RawEditMode {
139    Default,
140    Hashline,
141    Unknown(String),
142}
143
144impl<'de> Deserialize<'de> for RawEditMode {
145    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
146    where
147        D: Deserializer<'de>,
148    {
149        let value = String::deserialize(deserializer)?;
150        Ok(match value.as_str() {
151            "default" => Self::Default,
152            "hashline" => Self::Hashline,
153            _ => Self::Unknown(value),
154        })
155    }
156}
157
158#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "snake_case")]
160pub enum RawValidateOnEdit {
161    Syntax,
162    Full,
163}
164
165impl RawValidateOnEdit {
166    const fn as_str(self) -> &'static str {
167        match self {
168            Self::Syntax => "syntax",
169            Self::Full => "full",
170        }
171    }
172}
173
174#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "snake_case")]
176pub enum RawFormatter {
177    Biome,
178    Oxfmt,
179    Prettier,
180    Deno,
181    Ruff,
182    Black,
183    Rustfmt,
184    Goimports,
185    Gofmt,
186    None,
187}
188
189impl RawFormatter {
190    const fn as_str(self) -> &'static str {
191        match self {
192            Self::Biome => "biome",
193            Self::Oxfmt => "oxfmt",
194            Self::Prettier => "prettier",
195            Self::Deno => "deno",
196            Self::Ruff => "ruff",
197            Self::Black => "black",
198            Self::Rustfmt => "rustfmt",
199            Self::Goimports => "goimports",
200            Self::Gofmt => "gofmt",
201            Self::None => "none",
202        }
203    }
204}
205
206#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
207#[serde(rename_all = "snake_case")]
208pub enum RawChecker {
209    Tsc,
210    Tsgo,
211    Biome,
212    Pyright,
213    Ruff,
214    Cargo,
215    Go,
216    Staticcheck,
217    None,
218}
219
220impl RawChecker {
221    const fn as_str(self) -> &'static str {
222        match self {
223            Self::Tsc => "tsc",
224            Self::Tsgo => "tsgo",
225            Self::Biome => "biome",
226            Self::Pyright => "pyright",
227            Self::Ruff => "ruff",
228            Self::Cargo => "cargo",
229            Self::Go => "go",
230            Self::Staticcheck => "staticcheck",
231            Self::None => "none",
232        }
233    }
234}
235
236#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
237#[serde(rename_all = "snake_case")]
238pub enum RawConfigureWarningsDelivery {
239    Toast,
240    Log,
241    Chat,
242}
243
244#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
245#[serde(rename_all = "snake_case")]
246pub enum RawToolSurface {
247    Minimal,
248    Recommended,
249    All,
250}
251
252#[derive(Debug, Clone, Deserialize, PartialEq)]
253// Nested objects mirror TS sub-schemas, which are non-strict z.object (unknown
254// keys are silently stripped, the object survives). Only the TOP-LEVEL
255// RawAftConfig is strict (matches AftConfigSchema.strict()). Privileged denylist
256// fields are all top-level, so nested unknowns are harmlessly ignored.
257pub struct RawSemantic {
258    pub backend: Option<SemanticBackend>,
259    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
260    pub model: Option<String>,
261    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
262    pub base_url: Option<String>,
263    #[serde(default, deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
264    pub api_key_env: Option<String>,
265    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
266    pub timeout_ms: Option<u64>,
267    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
268    pub query_timeout_ms: Option<u64>,
269    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
270    pub max_batch_size: Option<usize>,
271    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
272    pub max_files: Option<usize>,
273}
274
275impl RawSemantic {
276    fn is_empty(&self) -> bool {
277        self.backend.is_none()
278            && self.model.is_none()
279            && self.base_url.is_none()
280            && self.api_key_env.is_none()
281            && self.timeout_ms.is_none()
282            && self.query_timeout_ms.is_none()
283            && self.max_batch_size.is_none()
284            && self.max_files.is_none()
285    }
286}
287
288#[derive(Debug, Clone, Deserialize, PartialEq)]
289pub struct RawLsp {
290    #[serde(default, deserialize_with = "deserialize_opt_lsp_servers")]
291    pub servers: Option<BTreeMap<String, RawLspServerEntry>>,
292    #[serde(
293        default,
294        deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec"
295    )]
296    pub disabled: Option<Vec<String>>,
297    pub python: Option<RawPythonLsp>,
298    pub diagnostics_on_edit: Option<bool>,
299    pub auto_install: Option<bool>,
300    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
301    pub grace_days: Option<u64>,
302    #[serde(default, deserialize_with = "deserialize_opt_versions_map")]
303    pub versions: Option<HashMap<String, String>>,
304}
305
306impl RawLsp {
307    fn is_empty(&self) -> bool {
308        self.servers.is_none()
309            && self.disabled.is_none()
310            && self.python.is_none()
311            && self.diagnostics_on_edit.is_none()
312            && self.auto_install.is_none()
313            && self.grace_days.is_none()
314            && self.versions.is_none()
315    }
316}
317
318#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
319#[serde(rename_all = "snake_case")]
320pub enum RawPythonLsp {
321    Pyright,
322    Ty,
323    Auto,
324}
325
326#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
327#[serde(default)]
328pub struct RawLspServerEntry {
329    #[serde(deserialize_with = "deserialize_opt_lsp_extensions")]
330    pub extensions: Option<Vec<String>>,
331    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
332    pub binary: Option<String>,
333    pub args: Option<Vec<String>>,
334    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string_vec")]
335    pub root_markers: Option<Vec<String>>,
336    pub disabled: Option<bool>,
337    pub env: Option<HashMap<String, String>>,
338    pub initialization_options: Option<Value>,
339}
340
341#[derive(Debug, Clone, Deserialize, PartialEq)]
342#[serde(untagged)]
343pub enum RawBash {
344    Bool(bool),
345    Features(RawBashFeatures),
346}
347
348#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
349#[serde(default)]
350pub struct RawBashFeatures {
351    pub rewrite: Option<bool>,
352    pub compress: Option<bool>,
353    pub background: Option<bool>,
354    pub host_fallback: Option<bool>,
355    pub subagent_background: Option<bool>,
356    pub detach_on_user_message: Option<bool>,
357    pub long_running_reminder_enabled: Option<bool>,
358    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
359    pub long_running_reminder_interval_ms: Option<u64>,
360    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
361    pub foreground_wait_window_ms: Option<u64>,
362    pub powershell_tool: Option<bool>,
363}
364
365#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
366#[serde(default)]
367pub struct RawExperimental {
368    pub bash: Option<RawExperimentalBash>,
369    pub lsp_ty: Option<bool>,
370}
371
372impl RawExperimental {
373    fn is_empty(&self) -> bool {
374        self.bash.is_none() && self.lsp_ty.is_none()
375    }
376}
377
378#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
379#[serde(default)]
380pub struct RawExperimentalBash {
381    pub rewrite: Option<bool>,
382    pub compress: Option<bool>,
383    pub background: Option<bool>,
384    pub long_running_reminder_enabled: Option<bool>,
385    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
386    pub long_running_reminder_interval_ms: Option<u64>,
387}
388
389impl RawExperimentalBash {
390    fn has_any_value(&self) -> bool {
391        self.rewrite.is_some()
392            || self.compress.is_some()
393            || self.background.is_some()
394            || self.long_running_reminder_enabled.is_some()
395            || self.long_running_reminder_interval_ms.is_some()
396    }
397
398    fn has_legacy_feature_flag(&self) -> bool {
399        self.rewrite.is_some() || self.compress.is_some() || self.background.is_some()
400    }
401}
402
403#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
404#[serde(default)]
405pub struct RawInspect {
406    pub enabled: Option<bool>,
407    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
408    pub diagnostics_timeout_ms: Option<u64>,
409    #[serde(deserialize_with = "deserialize_opt_nonnegative_f64")]
410    pub tier2_idle_minutes: Option<f64>,
411    pub categories: Option<HashMap<String, bool>>,
412    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
413    pub tier2_soft_deadline_ms: Option<u64>,
414    #[serde(deserialize_with = "deserialize_opt_drill_down_items")]
415    pub max_drill_down_items: Option<usize>,
416    pub duplicates: Option<RawInspectDuplicates>,
417}
418
419impl RawInspect {
420    fn is_empty(&self) -> bool {
421        self.enabled.is_none()
422            && self.diagnostics_timeout_ms.is_none()
423            && self.tier2_idle_minutes.is_none()
424            && self.categories.is_none()
425            && self.tier2_soft_deadline_ms.is_none()
426            && self.max_drill_down_items.is_none()
427            && self.duplicates.is_none()
428    }
429}
430
431/// Only `expected_mirrors` survives here: `lower_bound`, `discard_cost`, and
432/// `anonymize` were accepted-but-never-read knobs (the scanner hardcodes its
433/// cost bounds and anonymization rules), so they were removed from the schema
434/// rather than wired up.
435#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
436#[serde(default)]
437pub struct RawInspectDuplicates {
438    pub expected_mirrors: Option<Vec<[String; 2]>>,
439}
440
441impl RawInspectDuplicates {
442    fn is_empty(&self) -> bool {
443        self.expected_mirrors.is_none()
444    }
445}
446
447#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
448#[serde(default)]
449pub struct RawBridge {
450    #[serde(deserialize_with = "deserialize_opt_bridge_request_timeout_ms")]
451    pub request_timeout_ms: Option<u64>,
452    #[serde(deserialize_with = "deserialize_opt_positive_u64")]
453    pub hang_threshold: Option<u64>,
454}
455
456#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
457#[serde(default)]
458pub struct RawSubc {
459    pub connection_file: Option<String>,
460    pub client_reaper: Option<bool>,
461}
462
463#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
464#[serde(default)]
465pub struct RawWorktree {
466    pub ram_overlay: Option<bool>,
467}
468
469impl RawWorktree {
470    fn is_empty(&self) -> bool {
471        self.ram_overlay.is_none()
472    }
473}
474
475#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
476#[serde(default)]
477pub struct RawGhShim {
478    pub enabled: Option<bool>,
479    #[serde(deserialize_with = "deserialize_opt_trimmed_non_empty_string")]
480    pub binary_path: Option<String>,
481}
482
483#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
484#[serde(default)]
485pub struct RawGit {
486    #[serde(deserialize_with = "deserialize_opt_git_co_author")]
487    pub co_author: Option<String>,
488}
489
490#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
491#[serde(default)]
492pub struct RawBackup {
493    pub enabled: Option<bool>,
494    #[serde(default, deserialize_with = "deserialize_opt_positive_usize")]
495    pub max_depth: Option<usize>,
496    #[serde(default, deserialize_with = "deserialize_opt_positive_u64")]
497    pub max_file_size: Option<u64>,
498}
499
500#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
501#[serde(default)]
502pub struct RawSandbox {
503    pub enabled: Option<bool>,
504    pub write_allow: Option<Vec<PathBuf>>,
505    pub read_deny: Option<Vec<PathBuf>>,
506}
507
508/// Raw user-tier standing index configuration. Unlike normally stripped nested
509/// fields, unknown entry fields are retained only long enough to emit an
510/// explicit warn-and-ignore notice.
511#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
512#[serde(default)]
513pub struct RawIndex {
514    pub roots: Option<Vec<RawIndexRoot>>,
515}
516
517#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
518#[serde(default)]
519pub struct RawIndexRoot {
520    pub path: Option<String>,
521    pub indexes: Option<Vec<String>>,
522    #[serde(flatten)]
523    pub unknown: BTreeMap<String, Value>,
524}
525
526/// Resolve raw user/project config tiers into the flat core [`Config`].
527///
528/// Empty input is NOT special-cased: no config file is equivalent to an empty
529/// config object, so it still flows through the resolver and picks up the bash
530/// surface default (recommended ⇒ bash on), matching the TypeScript pipeline
531/// which always runs `resolveProjectOverridesForConfigure` even on `{}`.
532pub fn resolve_config(tiers: &[ConfigTier]) -> ResolveResult {
533    resolve_config_for_harness(tiers, None)
534}
535
536/// Resolve tiers for one active harness. Each tier applies its matching harness
537/// object before crossing the user/project trust boundary.
538pub fn resolve_config_for_harness(
539    tiers: &[ConfigTier],
540    harness: Option<&Harness>,
541) -> ResolveResult {
542    let mut merged = RawAftConfig::default();
543    let mut dropped = Vec::new();
544    let mut warnings = Vec::new();
545
546    for tier in tiers {
547        let Some(mut raw) = parse_tier(tier) else {
548            continue;
549        };
550        apply_harness_override(&mut raw, harness, tier, &mut warnings);
551        if let Some(RawEditMode::Unknown(value)) = raw.edit_mode.as_ref() {
552            warnings.push(ConfigWarning {
553                code: "invalid_edit_mode",
554                key: "edit_mode",
555                tier: tier.tier.clone(),
556                value: value.clone(),
557                message: format!("Unknown edit_mode value {value:?}; falling back to \"default\""),
558            });
559        }
560
561        if tier.tier == "user" {
562            merge_trusted_config(&mut merged, raw);
563        } else {
564            record_project_drops(&raw, &tier.tier, &mut dropped);
565            merge_project_config(&mut merged, raw);
566        }
567    }
568
569    let mut config = Config::default();
570    apply_resolved_config(&merged, &mut config);
571    config.index = resolve_index_config(merged.index.as_ref(), &mut warnings);
572    ResolveResult {
573        config,
574        dropped,
575        warnings,
576    }
577}
578
579/// Resolve raw config tiers into the core-domain config and RESET it onto an
580/// existing `base`, preserving only `base`'s process-state fields (storage_dir,
581/// harness, lsp_paths_extra, bash_permissions, …). This is the configure-path
582/// entry.
583///
584/// RESET, not overlay: the core-domain config is rebuilt from DEFAULT + the
585/// supplied tiers, so a field absent from the tiers returns to its default —
586/// it NEVER keeps `base`'s prior value. This closes a cross-bind privilege
587/// escalation: under the subc daemon a single `AppContext` per project root is
588/// shared across harness identities, and `configure` seeds `base` from the
589/// previous bind's config. With the old overlay semantics, a later low-trust
590/// bind (e.g. `mcp:*` or `fed:*`) that omitted a field inherited an earlier high-trust
591/// bind's capability for it (confirmed on the wire: `url_fetch_allow_private`
592/// SSRF, and `lsp_servers` arbitrary-binary). Reset-onto-default makes the
593/// resolved core config a pure function of this bind's own tiers and harness.
594///
595/// Parity-safe by construction: this routes through [`resolve_config_for_harness`]
596/// (the same harness-aware path the cross-language parity gate validates), which builds onto
597/// `Config::default()` — so reset-onto-default == overlay-onto-default there and
598/// no parity/unit fixture changes. Only this configure path, seeded from a prior
599/// config, changes behavior — which is exactly the leak site.
600///
601/// Process-state fields are not part of `RawAftConfig`; they are carried from
602/// `base` here and re-applied by `handle_configure`'s flat-param parsing
603/// afterwards, so plugin-mode behavior is unchanged (the plugin re-sends them on
604/// every configure). They are also unreachable as a subc escalation vector: a
605/// subc RouteBind sends only `config:[tiers]`, never the flat process-state
606/// params, so they stay at default for every subc bind regardless.
607pub fn resolve_config_onto(tiers: &[ConfigTier], base: &mut Config) -> Vec<DroppedKey> {
608    resolve_config_onto_with_diagnostics(tiers, base).dropped
609}
610
611/// Resolve configuration for the active harness into `base`, replacing
612/// configurable fields while retaining fields that describe the running process.
613pub fn resolve_config_onto_for_harness(
614    tiers: &[ConfigTier],
615    harness: &Harness,
616    base: &mut Config,
617) -> Vec<DroppedKey> {
618    resolve_config_onto_with_diagnostics_for_harness(tiers, Some(harness), base).dropped
619}
620
621/// Reset a runtime config while retaining both trust-boundary drops and
622/// non-fatal value warnings for the configure response.
623pub fn resolve_config_onto_with_diagnostics(
624    tiers: &[ConfigTier],
625    base: &mut Config,
626) -> ResolveDiagnostics {
627    resolve_config_onto_with_diagnostics_for_harness(tiers, None, base)
628}
629
630/// Harness-aware variant of [`resolve_config_onto_with_diagnostics`].
631pub fn resolve_config_onto_with_diagnostics_for_harness(
632    tiers: &[ConfigTier],
633    harness: Option<&Harness>,
634    base: &mut Config,
635) -> ResolveDiagnostics {
636    let ResolveResult {
637        mut config,
638        dropped,
639        warnings,
640    } = resolve_config_for_harness(tiers, harness);
641    carry_process_state(base, &mut config);
642    *base = config;
643    ResolveDiagnostics { dropped, warnings }
644}
645
646/// Carry the process-state (non-`RawAftConfig`) fields from `base` onto a
647/// freshly-resolved core config. EVERY `Config` field not copied here is
648/// core-domain and intentionally comes from the resolved tiers (reset). Keeping
649/// this list complete is load-bearing: a core field accidentally copied here
650/// would re-introduce cross-bind inheritance for it.
651fn carry_process_state(base: &Config, resolved: &mut Config) {
652    resolved.project_root = base.project_root.clone();
653    resolved.harness = base.harness.clone();
654    resolved.validation_depth = base.validation_depth;
655    resolved.checkpoint_ttl_hours = base.checkpoint_ttl_hours;
656    resolved.max_symbol_depth = base.max_symbol_depth;
657    resolved.diagnostic_cache_size = base.diagnostic_cache_size;
658    resolved.aft_search_registered = base.aft_search_registered;
659    resolved.max_background_bash_tasks = base.max_background_bash_tasks;
660    resolved.bash_permissions = base.bash_permissions;
661    resolved.search_index_max_file_size = base.search_index_max_file_size;
662    resolved.storage_dir = base.storage_dir.clone();
663    resolved.lsp_paths_extra = base.lsp_paths_extra.clone();
664    resolved.lsp_auto_install_binaries = base.lsp_auto_install_binaries.clone();
665    resolved.lsp_inflight_installs = base.lsp_inflight_installs.clone();
666}
667
668fn parse_tier(tier: &ConfigTier) -> Option<RawAftConfig> {
669    let stripped = strip_jsonc(&tier.doc);
670    let value = serde_json::from_str::<Value>(&stripped).ok()?;
671    let Value::Object(map) = value else {
672        return None;
673    };
674
675    match serde_json::from_value::<RawAftConfig>(Value::Object(map.clone())) {
676        Ok(config) => Some(config),
677        Err(_) => Some(parse_config_partially(map)),
678    }
679}
680
681fn parse_config_partially(raw_config: Map<String, Value>) -> RawAftConfig {
682    let mut partial = RawAftConfig::default();
683
684    for (key, value) in raw_config {
685        let mut one_field = Map::new();
686        one_field.insert(key, value);
687        if let Ok(section) = serde_json::from_value::<RawAftConfig>(Value::Object(one_field)) {
688            merge_trusted_config(&mut partial, section);
689        }
690    }
691
692    partial
693}
694
695fn apply_harness_override(
696    raw: &mut RawAftConfig,
697    harness: Option<&Harness>,
698    tier: &ConfigTier,
699    warnings: &mut Vec<ConfigWarning>,
700) {
701    let Some(overrides) = raw.harnesses.take() else {
702        return;
703    };
704    let Some(harness) = harness else {
705        return;
706    };
707    let key = harness.wire_label();
708    let Some(value) = overrides.get(&key) else {
709        return;
710    };
711    let Value::Object(mut override_map) = value.clone() else {
712        warnings.push(ConfigWarning {
713            code: "invalid_harness_override",
714            key: "harnesses",
715            tier: tier.tier.clone(),
716            value: key,
717            message: "Ignoring non-object harness override; overrides must be config objects"
718                .to_string(),
719        });
720        return;
721    };
722
723    if override_map.remove("harnesses").is_some() {
724        warnings.push(ConfigWarning {
725            code: "nested_harnesses_ignored",
726            key: "harnesses",
727            tier: tier.tier.clone(),
728            value: key.clone(),
729            message: format!(
730                "Ignoring nested harnesses in harnesses.{key}; harness overrides cannot recurse"
731            ),
732        });
733    }
734
735    match serde_json::from_value::<RawAftConfig>(Value::Object(override_map)) {
736        Ok(override_config) => merge_trusted_config(raw, override_config),
737        Err(_) => warnings.push(ConfigWarning {
738            code: "invalid_harness_override",
739            key: "harnesses",
740            tier: tier.tier.clone(),
741            value: key,
742            message: "Ignoring invalid harness override; it must use the root config shape"
743                .to_string(),
744        }),
745    }
746}
747
748fn merge_trusted_config(base: &mut RawAftConfig, override_config: RawAftConfig) {
749    if override_config.harnesses.is_some() {
750        base.harnesses = override_config.harnesses.clone();
751    }
752    if override_config.schema.is_some() {
753        base.schema = override_config.schema;
754    }
755    if override_config.enabled.is_some() {
756        base.enabled = override_config.enabled;
757    }
758    if override_config.edit_mode.is_some() {
759        base.edit_mode = override_config.edit_mode;
760    }
761    if override_config.format_on_edit.is_some() {
762        base.format_on_edit = override_config.format_on_edit;
763    }
764    if override_config.formatter_timeout_secs.is_some() {
765        base.formatter_timeout_secs = override_config.formatter_timeout_secs;
766    }
767    if override_config.type_checker_timeout_secs.is_some() {
768        base.type_checker_timeout_secs = override_config.type_checker_timeout_secs;
769    }
770    if override_config.validate_on_edit.is_some() {
771        base.validate_on_edit = override_config.validate_on_edit;
772    }
773    if override_config.formatter.is_some() {
774        base.formatter = override_config.formatter;
775    }
776    if override_config.checker.is_some() {
777        base.checker = override_config.checker;
778    }
779    if override_config.configure_warnings_delivery.is_some() {
780        base.configure_warnings_delivery = override_config.configure_warnings_delivery;
781    }
782    if override_config.hoist_builtin_tools.is_some() {
783        base.hoist_builtin_tools = override_config.hoist_builtin_tools;
784    }
785    if override_config.tool_surface.is_some() {
786        base.tool_surface = override_config.tool_surface;
787    }
788    if override_config.disabled_tools.is_some() {
789        base.disabled_tools = override_config.disabled_tools;
790    }
791    if override_config.restrict_to_project_root.is_some() {
792        base.restrict_to_project_root = override_config.restrict_to_project_root;
793    }
794    if override_config.search_index.is_some() {
795        base.search_index = override_config.search_index;
796    }
797    if override_config.index.is_some() {
798        base.index = override_config.index;
799    }
800    if override_config.semantic_search.is_some() {
801        base.semantic_search = override_config.semantic_search;
802    }
803    if override_config.callgraph_store.is_some() {
804        base.callgraph_store = override_config.callgraph_store;
805    }
806    if override_config.callgraph_chunk_size.is_some() {
807        base.callgraph_chunk_size = override_config.callgraph_chunk_size;
808    }
809    if override_config.inspect.is_some() {
810        base.inspect = override_config.inspect;
811    }
812    if override_config.backup.is_some() {
813        base.backup = override_config.backup;
814    }
815    if override_config.worktree.is_some() {
816        base.worktree = override_config.worktree;
817    }
818    if override_config.gh_shim.is_some() {
819        base.gh_shim = override_config.gh_shim;
820    }
821    if override_config.git.is_some() {
822        base.git = override_config.git;
823    }
824    if override_config.sandbox.is_some() {
825        base.sandbox = override_config.sandbox;
826    }
827    if override_config.bash.is_some() {
828        base.bash = override_config.bash;
829    }
830    if override_config.experimental.is_some() {
831        base.experimental = override_config.experimental;
832    }
833    if override_config.lsp.is_some() {
834        base.lsp = override_config.lsp;
835    }
836    if override_config.url_fetch_allow_private.is_some() {
837        base.url_fetch_allow_private = override_config.url_fetch_allow_private;
838    }
839    if override_config.semantic.is_some() {
840        base.semantic = override_config.semantic;
841    }
842    if override_config.auto_update.is_some() {
843        base.auto_update = override_config.auto_update;
844    }
845    if override_config.bridge.is_some() {
846        base.bridge = override_config.bridge;
847    }
848    if override_config.subc.is_some() {
849        base.subc = override_config.subc;
850    }
851}
852
853fn merge_project_config(base: &mut RawAftConfig, project: RawAftConfig) {
854    // Project-safe shallow top-level fields.
855    if project.enabled.is_some() {
856        base.enabled = project.enabled;
857    }
858    if project.edit_mode.is_some() {
859        base.edit_mode = project.edit_mode;
860    }
861    if project.format_on_edit.is_some() {
862        base.format_on_edit = project.format_on_edit;
863    }
864    if project.validate_on_edit.is_some() {
865        base.validate_on_edit = project.validate_on_edit;
866    }
867    if project.configure_warnings_delivery.is_some() {
868        base.configure_warnings_delivery = project.configure_warnings_delivery;
869    }
870    if project.hoist_builtin_tools.is_some() {
871        base.hoist_builtin_tools = project.hoist_builtin_tools;
872    }
873    if project.tool_surface.is_some() {
874        base.tool_surface = project.tool_surface;
875    }
876    if project.search_index.is_some() {
877        base.search_index = project.search_index;
878    }
879    if project.semantic_search.is_some() {
880        base.semantic_search = project.semantic_search;
881    }
882    if project.callgraph_store.is_some() {
883        base.callgraph_store = project.callgraph_store;
884    }
885    if project.callgraph_chunk_size.is_some() {
886        base.callgraph_chunk_size = project.callgraph_chunk_size;
887    }
888
889    merge_formatter_map(&mut base.formatter, project.formatter);
890    merge_checker_map(&mut base.checker, project.checker);
891    merge_disabled_tools(&mut base.disabled_tools, project.disabled_tools);
892    base.semantic = merge_semantic_config(base.semantic.clone(), project.semantic);
893    base.lsp = merge_lsp_config(base.lsp.clone(), project.lsp);
894    base.experimental = merge_experimental_config(base.experimental.clone(), project.experimental);
895    base.bash = merge_bash_config(base.bash.clone(), project.bash);
896    base.inspect = merge_inspect_config(base.inspect.clone(), project.inspect);
897    base.worktree = merge_worktree_config(base.worktree.clone(), project.worktree);
898    if project.git.is_some() {
899        base.git = project.git;
900    }
901    base.sandbox = merge_project_sandbox(base.sandbox.clone(), project.sandbox);
902}
903
904fn merge_project_sandbox(
905    base: Option<RawSandbox>,
906    project: Option<RawSandbox>,
907) -> Option<RawSandbox> {
908    let Some(project) = project else {
909        return base;
910    };
911    let mut sandbox = base.unwrap_or_default();
912    if let Some(project_denies) = project.read_deny {
913        let denies = sandbox.read_deny.get_or_insert_with(Vec::new);
914        for path in project_denies {
915            if !denies.contains(&path) {
916                denies.push(path);
917            }
918        }
919    }
920    // A project may ENABLE the sandbox for itself (hardening is one-way): a
921    // repo can opt its own bash into kernel confinement, but a project-tier
922    // `enabled: false` can never switch off what the user turned on.
923    if project.enabled == Some(true) {
924        sandbox.enabled = Some(true);
925    }
926    (sandbox.enabled.is_some() || sandbox.write_allow.is_some() || sandbox.read_deny.is_some())
927        .then_some(sandbox)
928}
929
930fn merge_formatter_map(
931    base: &mut Option<HashMap<String, RawFormatter>>,
932    override_map: Option<HashMap<String, RawFormatter>>,
933) {
934    let Some(override_map) = override_map else {
935        return;
936    };
937    if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
938        return;
939    }
940    let target = base.get_or_insert_with(HashMap::new);
941    target.extend(override_map);
942}
943
944fn merge_checker_map(
945    base: &mut Option<HashMap<String, RawChecker>>,
946    override_map: Option<HashMap<String, RawChecker>>,
947) {
948    let Some(override_map) = override_map else {
949        return;
950    };
951    if override_map.is_empty() && base.as_ref().is_none_or(HashMap::is_empty) {
952        return;
953    }
954    let target = base.get_or_insert_with(HashMap::new);
955    target.extend(override_map);
956}
957
958fn merge_disabled_tools(base: &mut Option<Vec<String>>, override_tools: Option<Vec<String>>) {
959    let Some(override_tools) = override_tools else {
960        return;
961    };
962    let mut merged = Vec::new();
963    let mut seen = HashSet::new();
964    for tool in base.iter().flatten() {
965        if seen.insert(tool.clone()) {
966            merged.push(tool.clone());
967        }
968    }
969    for tool in override_tools
970        .iter()
971        .filter(|tool| tool.as_str() != "aft_safety")
972    {
973        if seen.insert(tool.clone()) {
974            merged.push(tool.clone());
975        }
976    }
977    if !merged.is_empty() {
978        *base = Some(merged);
979    }
980}
981
982fn merge_semantic_config(
983    base: Option<RawSemantic>,
984    override_semantic: Option<RawSemantic>,
985) -> Option<RawSemantic> {
986    let mut semantic = base.unwrap_or(RawSemantic {
987        backend: None,
988        model: None,
989        base_url: None,
990        api_key_env: None,
991        timeout_ms: None,
992        query_timeout_ms: None,
993        max_batch_size: None,
994        max_files: None,
995    });
996
997    if let Some(project) = override_semantic {
998        if project.model.is_some() {
999            semantic.model = project.model;
1000        }
1001        if project.timeout_ms.is_some() {
1002            semantic.timeout_ms = project.timeout_ms;
1003        }
1004        if project.max_batch_size.is_some() {
1005            semantic.max_batch_size = project.max_batch_size;
1006        }
1007        if project.max_files.is_some() {
1008            semantic.max_files = project.max_files;
1009        }
1010    }
1011
1012    (!semantic.is_empty()).then_some(semantic)
1013}
1014
1015fn merge_lsp_config(base: Option<RawLsp>, override_lsp: Option<RawLsp>) -> Option<RawLsp> {
1016    let mut lsp = base.unwrap_or(RawLsp {
1017        servers: None,
1018        disabled: None,
1019        python: None,
1020        diagnostics_on_edit: None,
1021        auto_install: None,
1022        grace_days: None,
1023        versions: None,
1024    });
1025
1026    if let Some(project) = override_lsp {
1027        if project.python.is_some() {
1028            lsp.python = project.python;
1029        }
1030        if project.diagnostics_on_edit.is_some() {
1031            lsp.diagnostics_on_edit = project.diagnostics_on_edit;
1032        }
1033    }
1034
1035    (!lsp.is_empty()).then_some(lsp)
1036}
1037
1038fn merge_experimental_config(
1039    base: Option<RawExperimental>,
1040    override_experimental: Option<RawExperimental>,
1041) -> Option<RawExperimental> {
1042    let Some(override_experimental) = override_experimental else {
1043        return base;
1044    };
1045
1046    let mut experimental = base.unwrap_or_default();
1047    experimental.lsp_ty = override_experimental.lsp_ty.or(experimental.lsp_ty);
1048    experimental.bash = merge_experimental_bash(experimental.bash, override_experimental.bash);
1049
1050    (!experimental.is_empty()).then_some(experimental)
1051}
1052
1053fn merge_experimental_bash(
1054    base: Option<RawExperimentalBash>,
1055    override_bash: Option<RawExperimentalBash>,
1056) -> Option<RawExperimentalBash> {
1057    let Some(override_bash) = override_bash else {
1058        return base;
1059    };
1060    let mut bash = base.unwrap_or_default();
1061    bash.rewrite = override_bash.rewrite.or(bash.rewrite);
1062    bash.compress = override_bash.compress.or(bash.compress);
1063    bash.background = override_bash.background.or(bash.background);
1064    bash.long_running_reminder_enabled = override_bash
1065        .long_running_reminder_enabled
1066        .or(bash.long_running_reminder_enabled);
1067    bash.long_running_reminder_interval_ms = override_bash
1068        .long_running_reminder_interval_ms
1069        .or(bash.long_running_reminder_interval_ms);
1070
1071    bash.has_any_value().then_some(bash)
1072}
1073
1074fn merge_bash_config(base: Option<RawBash>, override_bash: Option<RawBash>) -> Option<RawBash> {
1075    match (base, override_bash) {
1076        (None, None) => None,
1077        (None, Some(override_bash)) => Some(override_bash),
1078        (Some(base), None) => Some(base),
1079        (Some(base), Some(override_bash)) => {
1080            let base = expand_bash_for_merge(&base);
1081            let override_features = expand_bash_for_merge(&override_bash);
1082            Some(RawBash::Features(RawBashFeatures {
1083                rewrite: override_features.rewrite.or(base.rewrite),
1084                compress: override_features.compress.or(base.compress),
1085                background: override_features.background.or(base.background),
1086                host_fallback: override_features.host_fallback.or(base.host_fallback),
1087                subagent_background: override_features
1088                    .subagent_background
1089                    .or(base.subagent_background),
1090                detach_on_user_message: override_features
1091                    .detach_on_user_message
1092                    .or(base.detach_on_user_message),
1093                long_running_reminder_enabled: override_features
1094                    .long_running_reminder_enabled
1095                    .or(base.long_running_reminder_enabled),
1096                long_running_reminder_interval_ms: override_features
1097                    .long_running_reminder_interval_ms
1098                    .or(base.long_running_reminder_interval_ms),
1099                foreground_wait_window_ms: override_features
1100                    .foreground_wait_window_ms
1101                    .or(base.foreground_wait_window_ms),
1102                powershell_tool: override_features.powershell_tool.or(base.powershell_tool),
1103            }))
1104        }
1105    }
1106}
1107
1108fn expand_bash_for_merge(value: &RawBash) -> RawBashFeatures {
1109    match value {
1110        RawBash::Bool(enabled) => RawBashFeatures {
1111            rewrite: Some(*enabled),
1112            compress: Some(*enabled),
1113            background: Some(*enabled),
1114            host_fallback: None,
1115            subagent_background: None,
1116            detach_on_user_message: None,
1117            long_running_reminder_enabled: None,
1118            long_running_reminder_interval_ms: None,
1119            foreground_wait_window_ms: None,
1120            powershell_tool: None,
1121        },
1122        RawBash::Features(features) => features.clone(),
1123    }
1124}
1125
1126fn merge_inspect_config(
1127    base: Option<RawInspect>,
1128    override_inspect: Option<RawInspect>,
1129) -> Option<RawInspect> {
1130    let Some(override_inspect) = override_inspect else {
1131        return base;
1132    };
1133
1134    let mut inspect = base.unwrap_or_default();
1135    inspect.enabled = override_inspect.enabled.or(inspect.enabled);
1136    if let Some(project_timeout) = override_inspect.diagnostics_timeout_ms {
1137        // A project may ask for more time, but it must not silently shrink another
1138        // consumer's diagnostic completeness by reducing the user's effective wait.
1139        inspect.diagnostics_timeout_ms = Some(
1140            project_timeout.max(
1141                inspect
1142                    .diagnostics_timeout_ms
1143                    .unwrap_or(DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS),
1144            ),
1145        );
1146    }
1147    inspect.tier2_idle_minutes = override_inspect
1148        .tier2_idle_minutes
1149        .or(inspect.tier2_idle_minutes);
1150    inspect.categories = override_inspect.categories.or(inspect.categories);
1151    inspect.tier2_soft_deadline_ms = override_inspect
1152        .tier2_soft_deadline_ms
1153        .or(inspect.tier2_soft_deadline_ms);
1154    inspect.max_drill_down_items = override_inspect
1155        .max_drill_down_items
1156        .or(inspect.max_drill_down_items);
1157    inspect.duplicates = merge_inspect_duplicates(inspect.duplicates, override_inspect.duplicates);
1158
1159    (!inspect.is_empty()).then_some(inspect)
1160}
1161
1162fn merge_worktree_config(
1163    base: Option<RawWorktree>,
1164    override_worktree: Option<RawWorktree>,
1165) -> Option<RawWorktree> {
1166    let Some(override_worktree) = override_worktree else {
1167        return base;
1168    };
1169
1170    let mut worktree = base.unwrap_or_default();
1171    worktree.ram_overlay = override_worktree.ram_overlay.or(worktree.ram_overlay);
1172    (!worktree.is_empty()).then_some(worktree)
1173}
1174
1175fn merge_inspect_duplicates(
1176    base: Option<RawInspectDuplicates>,
1177    override_duplicates: Option<RawInspectDuplicates>,
1178) -> Option<RawInspectDuplicates> {
1179    let Some(override_duplicates) = override_duplicates else {
1180        return base;
1181    };
1182
1183    let mut duplicates = base.unwrap_or_default();
1184    duplicates.expected_mirrors = override_duplicates
1185        .expected_mirrors
1186        .or(duplicates.expected_mirrors);
1187
1188    (!duplicates.is_empty()).then_some(duplicates)
1189}
1190
1191fn record_project_drops(raw: &RawAftConfig, tier: &str, dropped: &mut Vec<DroppedKey>) {
1192    if raw.restrict_to_project_root.is_some() {
1193        push_drop(dropped, "restrict_to_project_root", tier, USER_ONLY_REASON);
1194    }
1195    if raw.url_fetch_allow_private.is_some() {
1196        push_drop(dropped, "url_fetch_allow_private", tier, USER_ONLY_REASON);
1197    }
1198    if raw.formatter_timeout_secs.is_some() {
1199        push_drop(dropped, "formatter_timeout_secs", tier, USER_ONLY_REASON);
1200    }
1201    if raw.type_checker_timeout_secs.is_some() {
1202        push_drop(dropped, "type_checker_timeout_secs", tier, USER_ONLY_REASON);
1203    }
1204    if raw.auto_update.is_some() {
1205        push_drop(dropped, "auto_update", tier, USER_ONLY_REASON);
1206    }
1207    if raw.bridge.is_some() {
1208        push_drop(dropped, "bridge", tier, USER_ONLY_REASON);
1209    }
1210    if raw.subc.is_some() {
1211        push_drop(dropped, "subc", tier, USER_ONLY_REASON);
1212    }
1213    if raw.backup.is_some() {
1214        push_drop(dropped, "backup", tier, USER_ONLY_REASON);
1215    }
1216    if raw.gh_shim.is_some() {
1217        push_drop(dropped, "gh_shim", tier, USER_ONLY_REASON);
1218    }
1219    if raw
1220        .index
1221        .as_ref()
1222        .and_then(|index| index.roots.as_ref())
1223        .is_some()
1224    {
1225        push_drop(dropped, "index.roots", tier, USER_ONLY_REASON);
1226    }
1227    if let Some(sandbox) = &raw.sandbox {
1228        // enabled:true is an accepted project-tier hardening opt-in (merged by
1229        // merge_project_sandbox); only the weakening direction is dropped.
1230        if sandbox.enabled == Some(false) {
1231            push_drop(dropped, "sandbox.enabled", tier, USER_ONLY_REASON);
1232        }
1233        if sandbox.write_allow.is_some() {
1234            push_drop(dropped, "sandbox.write_allow", tier, USER_ONLY_REASON);
1235        }
1236    }
1237    if raw
1238        .disabled_tools
1239        .as_ref()
1240        .is_some_and(|tools| tools.iter().any(|tool| tool == "aft_safety"))
1241    {
1242        push_drop(dropped, "disabled_tools.aft_safety", tier, USER_ONLY_REASON);
1243    }
1244
1245    if let Some(semantic) = &raw.semantic {
1246        if semantic.backend.is_some() {
1247            push_drop(dropped, "semantic.backend", tier, SEMANTIC_SECRET_REASON);
1248        }
1249        if semantic.base_url.is_some() {
1250            push_drop(dropped, "semantic.base_url", tier, SEMANTIC_SECRET_REASON);
1251        }
1252        if semantic.api_key_env.is_some() {
1253            push_drop(
1254                dropped,
1255                "semantic.api_key_env",
1256                tier,
1257                SEMANTIC_SECRET_REASON,
1258            );
1259        }
1260        if semantic.query_timeout_ms.is_some() {
1261            push_drop(dropped, "semantic.query_timeout_ms", tier, USER_ONLY_REASON);
1262        }
1263    }
1264
1265    if let Some(lsp) = &raw.lsp {
1266        if lsp.servers.is_some() {
1267            push_drop(dropped, "lsp.servers", tier, LSP_USER_ONLY_REASON);
1268        }
1269        if lsp.versions.is_some() {
1270            push_drop(dropped, "lsp.versions", tier, LSP_USER_ONLY_REASON);
1271        }
1272        if lsp.auto_install.is_some() {
1273            push_drop(dropped, "lsp.auto_install", tier, LSP_USER_ONLY_REASON);
1274        }
1275        if lsp.grace_days.is_some() {
1276            push_drop(dropped, "lsp.grace_days", tier, LSP_USER_ONLY_REASON);
1277        }
1278        if lsp.disabled.is_some() {
1279            push_drop(dropped, "lsp.disabled", tier, LSP_USER_ONLY_REASON);
1280        }
1281    }
1282}
1283
1284fn push_drop(dropped: &mut Vec<DroppedKey>, key: &str, tier: &str, reason: &str) {
1285    dropped.push(DroppedKey {
1286        key: key.to_string(),
1287        tier: tier.to_string(),
1288        reason: reason.to_string(),
1289    });
1290}
1291
1292/// Apply merged core-domain fields onto a freshly defaulted `Config`. Absent
1293/// scalar fields therefore retain defaults, while semantic, inspect, and LSP
1294/// fields are fully resolved from the tiers. Process-state fields are not part
1295/// of `RawAftConfig` and are preserved separately by `resolve_config_onto`.
1296fn apply_resolved_config(raw: &RawAftConfig, config: &mut Config) {
1297    config.hashline_enabled = matches!(raw.edit_mode, Some(RawEditMode::Hashline));
1298    if let Some(value) = raw.hoist_builtin_tools {
1299        config.hoist_builtin_tools = value;
1300    }
1301    if let Some(value) = raw.format_on_edit {
1302        config.format_on_edit = value;
1303    }
1304    if let Some(value) = raw.formatter_timeout_secs {
1305        config.formatter_timeout_secs = value;
1306    }
1307    if let Some(value) = raw.type_checker_timeout_secs {
1308        config.type_checker_timeout_secs = value;
1309    }
1310    if let Some(value) = raw.validate_on_edit {
1311        config.validate_on_edit = Some(value.as_str().to_string());
1312    }
1313    if let Some(formatter) = &raw.formatter {
1314        config.formatter = formatter
1315            .iter()
1316            .map(|(language, formatter)| (language.clone(), formatter.as_str().to_string()))
1317            .collect();
1318    }
1319    if let Some(checker) = &raw.checker {
1320        config.checker = checker
1321            .iter()
1322            .map(|(language, checker)| (language.clone(), checker.as_str().to_string()))
1323            .collect();
1324    }
1325    if let Some(value) = raw.restrict_to_project_root {
1326        config.restrict_to_project_root = value;
1327    }
1328    if let Some(value) = raw.search_index {
1329        config.search_index = value;
1330    }
1331    if let Some(value) = raw.semantic_search {
1332        config.semantic_search = value;
1333    }
1334    if let Some(value) = raw.callgraph_store {
1335        config.callgraph_store = value;
1336    }
1337    if let Some(value) = raw.callgraph_chunk_size {
1338        config.callgraph_chunk_size = value;
1339    }
1340    if let Some(value) = raw.url_fetch_allow_private {
1341        config.url_fetch_allow_private = value;
1342    }
1343    config.semantic = resolve_semantic_config(raw.semantic.as_ref(), raw.subc.as_ref());
1344    config.inspect = resolve_inspect_config(raw.inspect.as_ref());
1345    config.backup = resolve_backup_config(raw.backup.as_ref());
1346    config.worktree = resolve_worktree_config(raw.worktree.as_ref());
1347    config.gh_shim = resolve_gh_shim_config(raw.gh_shim.as_ref());
1348    config.git = resolve_git_config(raw.git.as_ref());
1349    config.sandbox = resolve_sandbox_config(raw.sandbox.as_ref());
1350    resolve_lsp_config(raw, config);
1351    resolve_bash_fields(raw, config);
1352}
1353
1354fn resolve_index_config(raw: Option<&RawIndex>, warnings: &mut Vec<ConfigWarning>) -> IndexConfig {
1355    let Some(raw) = raw else {
1356        return IndexConfig::default();
1357    };
1358    let Some(roots) = raw.roots.as_ref() else {
1359        return IndexConfig::default();
1360    };
1361
1362    let home = std::env::var_os("HOME")
1363        .or_else(|| std::env::var_os("USERPROFILE"))
1364        .map(PathBuf::from);
1365    let mut normalized_roots = Vec::with_capacity(roots.len());
1366
1367    for (position, root) in roots.iter().enumerate() {
1368        for field in root.unknown.keys() {
1369            warnings.push(ConfigWarning {
1370                code: "unknown_index_root_field",
1371                key: "index.roots",
1372                tier: "user".to_string(),
1373                value: field.clone(),
1374                message: format!(
1375                    "Ignoring unknown field index.roots[{position}].{field}; only path and indexes are defined"
1376                ),
1377            });
1378        }
1379
1380        let result = (|| -> Result<IndexRootConfig, String> {
1381            let path = root.path.as_deref().ok_or_else(|| {
1382                "index.roots entry is missing required string field path".to_string()
1383            })?;
1384            expand_index_root_path(path, home.as_deref())?;
1385
1386            let indexes = root.indexes.as_ref().ok_or_else(|| {
1387                "index.roots entry is missing required non-empty indexes array".to_string()
1388            })?;
1389            if indexes.is_empty() {
1390                return Err("index.roots indexes must be a non-empty array".to_string());
1391            }
1392
1393            let mut normalized = Vec::with_capacity(indexes.len() + 1);
1394            for name in indexes {
1395                let kind = IndexKind::from_name(name).ok_or_else(|| {
1396                    format!(
1397                        "index.roots indexes contains unknown name {name:?}; valid names: search, semantic, callgraph"
1398                    )
1399                })?;
1400                if normalized.contains(&kind) {
1401                    return Err(format!(
1402                        "index.roots indexes contains duplicate name {name:?}"
1403                    ));
1404                }
1405                normalized.push(kind);
1406            }
1407            if normalized.contains(&IndexKind::Semantic) && !normalized.contains(&IndexKind::Search)
1408            {
1409                normalized.push(IndexKind::Search);
1410                warnings.push(ConfigWarning {
1411                    code: "index_dependency_closure",
1412                    key: "index.roots",
1413                    tier: "user".to_string(),
1414                    value: path.to_string(),
1415                    message: format!(
1416                        "Added search to index.roots[{position}].indexes because semantic depends on search"
1417                    ),
1418                });
1419            }
1420            normalized.sort_unstable();
1421            Ok(IndexRootConfig {
1422                path: path.to_string(),
1423                indexes: normalized,
1424            })
1425        })();
1426
1427        match result {
1428            Ok(root) => normalized_roots.push(root),
1429            Err(message) => {
1430                warnings.push(ConfigWarning {
1431                    code: "invalid_index_roots",
1432                    key: "index.roots",
1433                    tier: "user".to_string(),
1434                    value: position.to_string(),
1435                    message,
1436                });
1437                return IndexConfig::default();
1438            }
1439        }
1440    }
1441
1442    IndexConfig {
1443        roots: normalized_roots,
1444    }
1445}
1446
1447fn resolve_semantic_config(
1448    raw: Option<&RawSemantic>,
1449    subc: Option<&RawSubc>,
1450) -> SemanticBackendConfig {
1451    let mut semantic = SemanticBackendConfig::default();
1452    semantic.subc_connection_file = subc
1453        .and_then(|subc| subc.connection_file.as_deref())
1454        .map(str::trim)
1455        .filter(|path| !path.is_empty())
1456        .map(PathBuf::from);
1457    let Some(raw) = raw else {
1458        return semantic;
1459    };
1460
1461    if let Some(value) = raw.backend {
1462        semantic.backend = value;
1463        if value == SemanticBackend::Synapse && raw.model.is_none() {
1464            // Synapse has no implicit vector space. Leaving this empty lets backend
1465            // initialization return an honest missing-model configuration error.
1466            semantic.model.clear();
1467        }
1468    }
1469    if let Some(value) = &raw.model {
1470        semantic.model = value.clone();
1471    }
1472    if let Some(value) = &raw.base_url {
1473        semantic.base_url = Some(value.clone());
1474    }
1475    if let Some(value) = &raw.api_key_env {
1476        semantic.api_key_env = Some(value.clone());
1477    }
1478    if let Some(value) = raw.timeout_ms {
1479        semantic.timeout_ms = value.min(MAX_SEMANTIC_TIMEOUT_MS);
1480    }
1481    if let Some(value) = raw.query_timeout_ms {
1482        semantic.query_timeout_ms =
1483            value.clamp(MIN_SEMANTIC_QUERY_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS);
1484    }
1485    if let Some(value) = raw.max_batch_size {
1486        semantic.max_batch_size = value.min(MAX_SEMANTIC_BATCH_SIZE);
1487    }
1488    if let Some(value) = raw.max_files {
1489        semantic.max_files = value;
1490    }
1491
1492    semantic
1493}
1494
1495fn resolve_inspect_config(raw: Option<&RawInspect>) -> InspectConfig {
1496    let mut inspect = InspectConfig::default();
1497    let Some(raw) = raw else {
1498        return inspect;
1499    };
1500    if let Some(enabled) = raw.enabled {
1501        inspect.enabled = enabled;
1502    }
1503    if let Some(value) = raw.diagnostics_timeout_ms {
1504        inspect.diagnostics_timeout_ms = value.clamp(
1505            MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
1506            MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS,
1507        );
1508    }
1509    if let Some(expected_mirrors) = raw
1510        .duplicates
1511        .as_ref()
1512        .and_then(|duplicates| duplicates.expected_mirrors.clone())
1513    {
1514        inspect.duplicates.expected_mirrors = expected_mirrors;
1515    }
1516    inspect
1517}
1518
1519fn resolve_backup_config(raw: Option<&RawBackup>) -> BackupConfig {
1520    let mut backup = BackupConfig::default();
1521    if let Some(raw) = raw {
1522        if raw.enabled.is_some() {
1523            backup.enabled = raw.enabled;
1524        }
1525        if raw.max_depth.is_some() {
1526            backup.max_depth = raw.max_depth;
1527        }
1528        if raw.max_file_size.is_some() {
1529            backup.max_file_size = raw.max_file_size;
1530        }
1531    }
1532    backup
1533}
1534
1535fn resolve_worktree_config(raw: Option<&RawWorktree>) -> WorktreeConfig {
1536    let mut worktree = WorktreeConfig::default();
1537    if let Some(value) = raw.and_then(|raw| raw.ram_overlay) {
1538        worktree.ram_overlay = value;
1539    }
1540    worktree
1541}
1542
1543fn resolve_gh_shim_config(raw: Option<&RawGhShim>) -> GhShimConfig {
1544    let mut gh_shim = GhShimConfig::default();
1545    if let Some(value) = raw.and_then(|raw| raw.enabled) {
1546        gh_shim.enabled = value;
1547    }
1548    gh_shim.binary_path = raw
1549        .and_then(|raw| raw.binary_path.as_ref())
1550        .map(PathBuf::from);
1551    gh_shim
1552}
1553
1554fn resolve_git_config(raw: Option<&RawGit>) -> GitConfig {
1555    GitConfig {
1556        co_author: raw
1557            .and_then(|raw| raw.co_author.clone())
1558            .unwrap_or_else(|| "off".to_string()),
1559    }
1560}
1561
1562fn resolve_sandbox_config(raw: Option<&RawSandbox>) -> SandboxConfig {
1563    let Some(raw) = raw else {
1564        return SandboxConfig::default();
1565    };
1566    SandboxConfig {
1567        enabled: raw.enabled.unwrap_or(false),
1568        write_allow: raw.write_allow.clone().unwrap_or_default(),
1569        read_deny: raw.read_deny.clone().unwrap_or_default(),
1570    }
1571}
1572
1573fn resolve_lsp_config(raw: &RawAftConfig, config: &mut Config) {
1574    let lsp = raw.lsp.as_ref();
1575    let mut disabled: HashSet<String> = lsp
1576        .and_then(|lsp| lsp.disabled.as_ref())
1577        .into_iter()
1578        .flatten()
1579        .map(|value| value.to_ascii_lowercase())
1580        .collect();
1581    let mut experimental_ty = raw
1582        .experimental
1583        .as_ref()
1584        .and_then(|experimental| experimental.lsp_ty);
1585
1586    match lsp.and_then(|lsp| lsp.python).unwrap_or(RawPythonLsp::Auto) {
1587        RawPythonLsp::Ty => {
1588            experimental_ty = Some(true);
1589            disabled.insert("python".to_string());
1590        }
1591        RawPythonLsp::Pyright => {
1592            experimental_ty = Some(false);
1593            disabled.insert("ty".to_string());
1594        }
1595        RawPythonLsp::Auto => {}
1596    }
1597
1598    if let Some(value) = experimental_ty {
1599        config.experimental_lsp_ty = value;
1600    }
1601
1602    if let Some(value) = lsp.and_then(|lsp| lsp.diagnostics_on_edit) {
1603        config.diagnostics_on_edit = value;
1604    }
1605
1606    if let Some(servers) = lsp.and_then(|lsp| lsp.servers.as_ref()) {
1607        config.lsp_servers = servers
1608            .iter()
1609            .map(|(id, server)| UserServerDef {
1610                id: id.clone(),
1611                extensions: server
1612                    .extensions
1613                    .clone()
1614                    .unwrap_or_default()
1615                    .into_iter()
1616                    .map(|extension| extension.trim_start_matches('.').to_string())
1617                    .collect(),
1618                binary: server.binary.clone().unwrap_or_default(),
1619                args: server.args.clone().unwrap_or_default(),
1620                root_markers: server
1621                    .root_markers
1622                    .clone()
1623                    .unwrap_or_else(|| vec![".git".to_string()]),
1624                env: server.env.clone().unwrap_or_default(),
1625                initialization_options: server.initialization_options.clone(),
1626                disabled: server.disabled.unwrap_or(false),
1627            })
1628            .collect();
1629    }
1630
1631    if !disabled.is_empty() {
1632        config.disabled_lsp = disabled;
1633    }
1634}
1635
1636#[derive(Debug, Clone, PartialEq, Eq)]
1637struct ResolvedBashConfig {
1638    enabled: bool,
1639    rewrite: bool,
1640    compress: bool,
1641    background: bool,
1642    host_fallback: bool,
1643    subagent_background: bool,
1644    detach_on_user_message: bool,
1645    long_running_reminder_enabled: Option<bool>,
1646    long_running_reminder_interval_ms: Option<u64>,
1647    foreground_wait_window_ms: u64,
1648    powershell_tool: bool,
1649}
1650
1651fn resolve_bash_fields(raw: &RawAftConfig, config: &mut Config) {
1652    let bash = resolve_bash_config(raw);
1653    // The plugins use `enabled` and `subagent_background` when registering bash
1654    // capabilities. Rust resolves them only to accept and merge the same config;
1655    // they do not control engine behavior.
1656    let _registration_only = (bash.enabled, bash.subagent_background);
1657    config.bash.host_fallback = bash.host_fallback;
1658    config.bash.detach_on_user_message = bash.detach_on_user_message;
1659    config.bash.powershell_tool = bash.powershell_tool;
1660    config.experimental_bash_rewrite = bash.rewrite;
1661    config.experimental_bash_compress = bash.compress;
1662    config.experimental_bash_background = bash.background;
1663    config.foreground_wait_window_ms = bash.foreground_wait_window_ms;
1664    if let Some(value) = bash.long_running_reminder_enabled {
1665        config.bash_long_running_reminder_enabled = value;
1666    }
1667    if let Some(value) = bash.long_running_reminder_interval_ms {
1668        config.bash_long_running_reminder_interval_ms = value;
1669    }
1670}
1671
1672fn resolve_bash_config(raw: &RawAftConfig) -> ResolvedBashConfig {
1673    let top = raw.bash.as_ref();
1674    let legacy = raw
1675        .experimental
1676        .as_ref()
1677        .and_then(|experimental| experimental.bash.as_ref());
1678    let surface = raw.tool_surface.unwrap_or(RawToolSurface::Recommended);
1679    let surface_default_enabled = surface != RawToolSurface::Minimal;
1680
1681    let top_features = match top {
1682        Some(RawBash::Features(features)) => Some(features),
1683        _ => None,
1684    };
1685    let reminder_enabled = top_features
1686        .and_then(|features| features.long_running_reminder_enabled)
1687        .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_enabled));
1688    let reminder_interval = top_features
1689        .and_then(|features| features.long_running_reminder_interval_ms)
1690        .or_else(|| legacy.and_then(|legacy| legacy.long_running_reminder_interval_ms));
1691    let top_host_fallback = top_features
1692        .and_then(|features| features.host_fallback)
1693        .unwrap_or(false);
1694    let top_subagent_background = top_features
1695        .and_then(|features| features.subagent_background)
1696        .unwrap_or(false);
1697    let top_detach_on_user_message = top_features
1698        .and_then(|features| features.detach_on_user_message)
1699        .unwrap_or(true);
1700    let raw_foreground_wait = top_features.and_then(|features| features.foreground_wait_window_ms);
1701    let top_powershell_tool = top_features
1702        .and_then(|features| features.powershell_tool)
1703        .unwrap_or(false);
1704    let foreground_wait_window_ms = raw_foreground_wait
1705        .unwrap_or(FOREGROUND_WAIT_WINDOW_DEFAULT_MS)
1706        .max(FOREGROUND_WAIT_WINDOW_MIN_MS);
1707
1708    let base = ResolvedBashConfig {
1709        enabled: false,
1710        rewrite: false,
1711        compress: false,
1712        background: false,
1713        host_fallback: false,
1714        subagent_background: false,
1715        detach_on_user_message: true,
1716        long_running_reminder_enabled: reminder_enabled,
1717        long_running_reminder_interval_ms: reminder_interval,
1718        foreground_wait_window_ms,
1719        powershell_tool: false,
1720    };
1721
1722    match top {
1723        Some(RawBash::Bool(false)) => base,
1724        Some(RawBash::Bool(true)) => ResolvedBashConfig {
1725            enabled: true,
1726            rewrite: true,
1727            compress: true,
1728            background: true,
1729            ..base
1730        },
1731        Some(RawBash::Features(features)) => ResolvedBashConfig {
1732            enabled: true,
1733            rewrite: features.rewrite.unwrap_or(true),
1734            compress: features.compress.unwrap_or(true),
1735            background: features.background.unwrap_or(true),
1736            host_fallback: top_host_fallback,
1737            subagent_background: top_subagent_background,
1738            detach_on_user_message: top_detach_on_user_message,
1739            powershell_tool: top_powershell_tool,
1740            ..base
1741        },
1742        None => {
1743            if legacy.is_some_and(RawExperimentalBash::has_legacy_feature_flag) {
1744                let legacy = legacy.cloned().unwrap_or_default();
1745                let rewrite = legacy.rewrite == Some(true);
1746                let compress = legacy.compress == Some(true);
1747                let background = legacy.background == Some(true);
1748                return ResolvedBashConfig {
1749                    enabled: rewrite || compress || background,
1750                    rewrite,
1751                    compress,
1752                    background,
1753                    ..base
1754                };
1755            }
1756
1757            ResolvedBashConfig {
1758                enabled: surface_default_enabled,
1759                rewrite: surface_default_enabled,
1760                compress: surface_default_enabled,
1761                background: surface_default_enabled,
1762                ..base
1763            }
1764        }
1765    }
1766}
1767
1768fn deserialize_opt_git_co_author<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
1769where
1770    D: Deserializer<'de>,
1771{
1772    let value = Option::<String>::deserialize(deserializer)?;
1773    value
1774        .map(|value| {
1775            normalize_git_co_author(&value).ok_or_else(|| {
1776                de::Error::custom(
1777                    "git.co_author must be 'off', 'auto', or an explicit 'Name <email>' identity",
1778                )
1779            })
1780        })
1781        .transpose()
1782}
1783
1784fn deserialize_opt_trimmed_non_empty_string<'de, D>(
1785    deserializer: D,
1786) -> Result<Option<String>, D::Error>
1787where
1788    D: Deserializer<'de>,
1789{
1790    let value = Option::<String>::deserialize(deserializer)?;
1791    value
1792        .map(|value| {
1793            let trimmed = value.trim().to_string();
1794            if trimmed.is_empty() {
1795                Err(de::Error::custom("must be a non-empty string"))
1796            } else {
1797                Ok(trimmed)
1798            }
1799        })
1800        .transpose()
1801}
1802
1803fn deserialize_opt_trimmed_non_empty_string_vec<'de, D>(
1804    deserializer: D,
1805) -> Result<Option<Vec<String>>, D::Error>
1806where
1807    D: Deserializer<'de>,
1808{
1809    let value = Option::<Vec<String>>::deserialize(deserializer)?;
1810    value
1811        .map(|values| {
1812            values
1813                .into_iter()
1814                .map(|value| {
1815                    let trimmed = value.trim().to_string();
1816                    if trimmed.is_empty() {
1817                        Err(de::Error::custom("array entries must be non-empty strings"))
1818                    } else {
1819                        Ok(trimmed)
1820                    }
1821                })
1822                .collect()
1823        })
1824        .transpose()
1825}
1826
1827fn deserialize_opt_lsp_extensions<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
1828where
1829    D: Deserializer<'de>,
1830{
1831    let value = Option::<Vec<String>>::deserialize(deserializer)?;
1832    value
1833        .map(|values| {
1834            if values.is_empty() {
1835                return Err(de::Error::custom(
1836                    "extensions must contain at least one entry",
1837                ));
1838            }
1839            values
1840                .into_iter()
1841                .map(|value| {
1842                    let trimmed = value.trim().to_string();
1843                    if trimmed.is_empty() || trimmed.trim_start_matches('.').is_empty() {
1844                        Err(de::Error::custom(
1845                            "extension must include characters other than leading dots",
1846                        ))
1847                    } else {
1848                        Ok(trimmed)
1849                    }
1850                })
1851                .collect()
1852        })
1853        .transpose()
1854}
1855
1856fn deserialize_opt_lsp_servers<'de, D>(
1857    deserializer: D,
1858) -> Result<Option<BTreeMap<String, RawLspServerEntry>>, D::Error>
1859where
1860    D: Deserializer<'de>,
1861{
1862    let value = Option::<BTreeMap<String, RawLspServerEntry>>::deserialize(deserializer)?;
1863    value
1864        .map(|entries| {
1865            entries
1866                .into_iter()
1867                .map(|(key, value)| {
1868                    let trimmed = key.trim().to_string();
1869                    if trimmed.is_empty() {
1870                        Err(de::Error::custom(
1871                            "lsp.servers keys must be non-empty strings",
1872                        ))
1873                    } else {
1874                        Ok((trimmed, value))
1875                    }
1876                })
1877                .collect()
1878        })
1879        .transpose()
1880}
1881
1882fn deserialize_opt_versions_map<'de, D>(
1883    deserializer: D,
1884) -> Result<Option<HashMap<String, String>>, D::Error>
1885where
1886    D: Deserializer<'de>,
1887{
1888    let value = Option::<HashMap<String, String>>::deserialize(deserializer)?;
1889    value
1890        .map(|entries| {
1891            entries
1892                .into_iter()
1893                .map(|(key, value)| {
1894                    let trimmed_key = key.trim().to_string();
1895                    let trimmed_value = value.trim().to_string();
1896                    if trimmed_key.is_empty() || trimmed_value.is_empty() {
1897                        Err(de::Error::custom(
1898                            "lsp.versions keys and values must be non-empty strings",
1899                        ))
1900                    } else {
1901                        Ok((trimmed_key, trimmed_value))
1902                    }
1903                })
1904                .collect()
1905        })
1906        .transpose()
1907}
1908
1909fn deserialize_opt_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1910where
1911    D: Deserializer<'de>,
1912{
1913    let value = Option::<u64>::deserialize(deserializer)?;
1914    value
1915        .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1916        .transpose()
1917}
1918
1919fn deserialize_opt_positive_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1920where
1921    D: Deserializer<'de>,
1922{
1923    let value = Option::<u64>::deserialize(deserializer)?;
1924    match value {
1925        Some(0) => Err(de::Error::custom("must be a positive integer")),
1926        other => Ok(other),
1927    }
1928}
1929
1930fn deserialize_opt_positive_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1931where
1932    D: Deserializer<'de>,
1933{
1934    let value = deserialize_opt_positive_u64(deserializer)?;
1935    value
1936        .map(|value| usize::try_from(value).map_err(|_| de::Error::custom("value is too large")))
1937        .transpose()
1938}
1939
1940fn deserialize_opt_timeout_secs<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1941where
1942    D: Deserializer<'de>,
1943{
1944    let value = Option::<u64>::deserialize(deserializer)?;
1945    match value {
1946        Some(value) if !(1..=600).contains(&value) => {
1947            Err(de::Error::custom("timeout must be in 1..=600 seconds"))
1948        }
1949        Some(value) => u32::try_from(value)
1950            .map(Some)
1951            .map_err(|_| de::Error::custom("timeout is too large")),
1952        None => Ok(None),
1953    }
1954}
1955
1956fn deserialize_opt_bridge_request_timeout_ms<'de, D>(
1957    deserializer: D,
1958) -> Result<Option<u64>, D::Error>
1959where
1960    D: Deserializer<'de>,
1961{
1962    let value = Option::<u64>::deserialize(deserializer)?;
1963    match value {
1964        Some(value) if value < 1_000 => Err(de::Error::custom(
1965            "bridge.request_timeout_ms must be at least 1000",
1966        )),
1967        other => Ok(other),
1968    }
1969}
1970
1971fn deserialize_opt_nonnegative_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
1972where
1973    D: Deserializer<'de>,
1974{
1975    let value = Option::<f64>::deserialize(deserializer)?;
1976    match value {
1977        Some(value) if value < 0.0 => Err(de::Error::custom("must be non-negative")),
1978        other => Ok(other),
1979    }
1980}
1981
1982fn deserialize_opt_drill_down_items<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
1983where
1984    D: Deserializer<'de>,
1985{
1986    let value = Option::<u64>::deserialize(deserializer)?;
1987    match value {
1988        Some(value) if value == 0 || value > 100 => {
1989            Err(de::Error::custom("max_drill_down_items must be in 1..=100"))
1990        }
1991        Some(value) => usize::try_from(value)
1992            .map(Some)
1993            .map_err(|_| de::Error::custom("max_drill_down_items is too large")),
1994        None => Ok(None),
1995    }
1996}
1997
1998#[cfg(test)]
1999mod tests {
2000    use super::*;
2001
2002    fn tier(tier: &str, doc: &str) -> ConfigTier {
2003        ConfigTier {
2004            tier: tier.to_string(),
2005            source: format!("/tmp/{tier}/aft.jsonc"),
2006            doc: doc.to_string(),
2007        }
2008    }
2009
2010    fn drop_keys(result: &ResolveResult) -> Vec<String> {
2011        result
2012            .dropped
2013            .iter()
2014            .map(|dropped| dropped.key.clone())
2015            .collect()
2016    }
2017
2018    /// Security invariant (Oracle drift decision): nested objects are non-strict
2019    /// (match TS z.object — unknown nested keys are stripped, object survives),
2020    /// but the TOP-LEVEL RawAftConfig stays strict. A privileged process-state
2021    /// field is top-level, so a project tier trying to smuggle one still hits the
2022    /// strict top-level → that tier fails full parse, and partial-parse drops the
2023    /// unknown key. It can NEVER reach Config.
2024    #[test]
2025    fn nested_unknown_keys_are_stripped_but_top_level_privileged_keys_cannot_smuggle() {
2026        // Nested unknown key: stripped, object survives — parity with TS (golden
2027        // `bash_unknown_nested_key`). `bash: { unknown_key }` resolves like
2028        // `bash: {}` → object form → bash ENABLED (object presence beats the
2029        // minimal surface default). The point: the unknown key did not fail the
2030        // parse — the object survived and resolved.
2031        let nested = resolve_config(&[tier(
2032            "user",
2033            r#"{ "tool_surface": "minimal", "bash": { "unknown_key": true } }"#,
2034        )]);
2035        assert!(nested.config.experimental_bash_rewrite);
2036        assert!(nested.config.experimental_bash_compress);
2037        assert!(nested.config.experimental_bash_background);
2038
2039        // Top-level privileged process-state field (storage_dir) from a PROJECT
2040        // tier: not in RawAftConfig → full parse fails → partial-parse drops it.
2041        // It must never appear in Config (Config keeps its default storage_dir).
2042        let smuggle = resolve_config(&[
2043            tier("user", r#"{ "search_index": true }"#),
2044            tier(
2045                "project",
2046                r#"{ "storage_dir": "/tmp/evil", "bash_permissions": true, "search_index": false }"#,
2047            ),
2048        ]);
2049        // The valid project key (search_index) still applies via partial-parse...
2050        assert!(!smuggle.config.search_index);
2051        // ...but the smuggled process-state fields never reach Config.
2052        assert!(smuggle.config.storage_dir.is_none());
2053        assert!(!smuggle.config.bash_permissions);
2054    }
2055
2056    #[test]
2057    fn config_resolve_empty_tiers_applies_bash_surface_default() {
2058        // No config file ⇒ empty config object, which still flows through the
2059        // resolver and picks up the bash surface default (recommended ⇒ on),
2060        // matching the TS pipeline (golden fixture `empty`). NOT Config::default()
2061        // — that would leave bash off, diverging from TS.
2062        let result = resolve_config(&[]);
2063        let default_config = Config::default();
2064
2065        assert!(result.dropped.is_empty());
2066        // Non-bash fields stay at runtime default.
2067        assert_eq!(result.config.format_on_edit, default_config.format_on_edit);
2068        assert_eq!(result.config.search_index, default_config.search_index);
2069        assert_eq!(
2070            result.config.semantic_search,
2071            default_config.semantic_search
2072        );
2073        assert_eq!(result.config.semantic, default_config.semantic);
2074        assert_eq!(
2075            result.config.inspect.enabled,
2076            default_config.inspect.enabled
2077        );
2078        assert_eq!(result.config.lsp_servers.len(), 0);
2079        // Bash surface default: recommended ⇒ rewrite/compress/background all on.
2080        assert!(result.config.experimental_bash_rewrite);
2081        assert!(result.config.experimental_bash_compress);
2082        assert!(result.config.experimental_bash_background);
2083    }
2084
2085    #[test]
2086    fn config_resolve_user_only_config_applies_fields() {
2087        let result = resolve_config(&[tier(
2088            "user",
2089            r#"{
2090              "$schema": "https://example.test/aft.schema.json",
2091              "format_on_edit": false,
2092              "formatter_timeout_secs": 42,
2093              "type_checker_timeout_secs": 43,
2094              "validate_on_edit": "full",
2095              "formatter": { "rust": "rustfmt", "typescript": "prettier" },
2096              "checker": { "rust": "cargo", "typescript": "tsc" },
2097              "restrict_to_project_root": true,
2098              "search_index": true,
2099              "semantic_search": true,
2100              "callgraph_store": false,
2101              "callgraph_chunk_size": 17,
2102              "url_fetch_allow_private": true,
2103              "semantic": {
2104                "backend": "openai_compatible",
2105                "model": "  user-model  ",
2106                "base_url": "https://semantic.example.test",
2107                "api_key_env": "AFT_API_KEY",
2108                "timeout_ms": 12345,
2109                "query_timeout_ms": 2345,
2110                "max_batch_size": 12,
2111                "max_files": 3456
2112              },
2113              "inspect": { "enabled": false, "diagnostics_timeout_ms": 15000 },
2114              "experimental": { "lsp_ty": true },
2115              "lsp": {
2116                "servers": {
2117                  "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
2118                },
2119                "disabled": ["Python"],
2120                "python": "pyright"
2121              },
2122              "bash": { "rewrite": false, "compress": true, "background": false,
2123                        "long_running_reminder_enabled": false,
2124                        "long_running_reminder_interval_ms": 123000 }
2125            }"#,
2126        )]);
2127
2128        assert!(result.dropped.is_empty());
2129        assert!(!result.config.format_on_edit);
2130        assert_eq!(result.config.formatter_timeout_secs, 42);
2131        assert_eq!(result.config.type_checker_timeout_secs, 43);
2132        assert_eq!(result.config.validate_on_edit.as_deref(), Some("full"));
2133        assert_eq!(
2134            result.config.formatter.get("rust").map(String::as_str),
2135            Some("rustfmt")
2136        );
2137        assert_eq!(
2138            result.config.checker.get("typescript").map(String::as_str),
2139            Some("tsc")
2140        );
2141        assert!(result.config.restrict_to_project_root);
2142        assert!(result.config.search_index);
2143        assert!(result.config.semantic_search);
2144        assert!(!result.config.callgraph_store);
2145        assert_eq!(result.config.callgraph_chunk_size, 17);
2146        assert!(result.config.url_fetch_allow_private);
2147        assert_eq!(
2148            result.config.semantic.backend,
2149            SemanticBackend::OpenAiCompatible
2150        );
2151        assert_eq!(result.config.semantic.model, "user-model");
2152        assert_eq!(
2153            result.config.semantic.base_url.as_deref(),
2154            Some("https://semantic.example.test")
2155        );
2156        assert_eq!(
2157            result.config.semantic.api_key_env.as_deref(),
2158            Some("AFT_API_KEY")
2159        );
2160        assert_eq!(result.config.semantic.timeout_ms, 12345);
2161        assert_eq!(result.config.semantic.query_timeout_ms, 2345);
2162        assert_eq!(result.config.semantic.max_batch_size, 12);
2163        assert_eq!(result.config.semantic.max_files, 3456);
2164        assert!(!result.config.inspect.enabled);
2165        assert_eq!(result.config.inspect.diagnostics_timeout_ms, 15_000);
2166        assert!(!result.config.experimental_lsp_ty);
2167        assert!(result.config.disabled_lsp.contains("ty"));
2168        assert_eq!(result.config.lsp_servers.len(), 1);
2169        assert_eq!(result.config.lsp_servers[0].id, "rust");
2170        assert_eq!(
2171            result.config.lsp_servers[0].extensions,
2172            vec!["rs".to_string()]
2173        );
2174        assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
2175        assert_eq!(result.config.lsp_servers[0].args, Vec::<String>::new());
2176        assert_eq!(
2177            result.config.lsp_servers[0].root_markers,
2178            vec![".git".to_string()]
2179        );
2180        assert!(!result.config.experimental_bash_rewrite);
2181        assert!(result.config.experimental_bash_compress);
2182        assert!(!result.config.experimental_bash_background);
2183        assert!(!result.config.bash_long_running_reminder_enabled);
2184        assert_eq!(result.config.bash_long_running_reminder_interval_ms, 123000);
2185    }
2186
2187    #[test]
2188    fn edit_mode_resolves_at_user_and_project_tiers_with_project_precedence() {
2189        let hashline = resolve_config(&[
2190            tier("user", r#"{"edit_mode":"default"}"#),
2191            tier("project", r#"{"edit_mode":"hashline"}"#),
2192        ]);
2193        assert!(hashline.config.hashline_enabled);
2194        assert!(hashline.dropped.is_empty());
2195
2196        let default = resolve_config(&[
2197            tier("user", r#"{"edit_mode":"hashline"}"#),
2198            tier("project", r#"{"edit_mode":"default"}"#),
2199        ]);
2200        assert!(!default.config.hashline_enabled);
2201        assert!(default.dropped.is_empty());
2202    }
2203
2204    #[test]
2205    fn harness_overrides_select_the_active_harness_and_preserve_tier_order() {
2206        let tiers = [
2207            tier(
2208                "user",
2209                r#"{
2210                  "hoist_builtin_tools": false,
2211                  "harnesses": {
2212                    "opencode": { "hoist_builtin_tools": true },
2213                    "pi": { "hoist_builtin_tools": false }
2214                  }
2215                }"#,
2216            ),
2217            tier(
2218                "project",
2219                r#"{
2220                  "hoist_builtin_tools": false,
2221                  "harnesses": { "opencode": { "hoist_builtin_tools": true } }
2222                }"#,
2223            ),
2224        ];
2225
2226        let opencode = resolve_config_for_harness(&tiers, Some(&Harness::Opencode));
2227        let pi = resolve_config_for_harness(&tiers, Some(&Harness::Pi));
2228
2229        assert!(opencode.config.hoist_builtin_tools);
2230        assert!(!pi.config.hoist_builtin_tools);
2231    }
2232
2233    #[test]
2234    fn project_harness_overrides_are_filtered_at_the_existing_trust_boundary() {
2235        let result = resolve_config_for_harness(
2236            &[
2237                tier(
2238                    "user",
2239                    r#"{
2240                      "restrict_to_project_root": true,
2241                      "semantic": {
2242                        "backend": "ollama",
2243                        "base_url": "http://localhost:11434",
2244                        "api_key_env": "USER_KEY"
2245                      },
2246                      "sandbox": { "enabled": true, "write_allow": ["/user/write"] }
2247                    }"#,
2248                ),
2249                tier(
2250                    "project",
2251                    r#"{
2252                      "harnesses": {
2253                        "opencode": {
2254                          "edit_mode": "hashline",
2255                          "restrict_to_project_root": false,
2256                          "semantic": {
2257                            "backend": "openai_compatible",
2258                            "base_url": "https://evil.example.test",
2259                            "api_key_env": "EVIL_KEY"
2260                          },
2261                          "subc": { "connection_file": "/tmp/evil-subc.json" },
2262                          "sandbox": { "enabled": false, "write_allow": ["/project/write"] }
2263                        }
2264                      }
2265                    }"#,
2266                ),
2267            ],
2268            Some(&Harness::Opencode),
2269        );
2270
2271        assert!(result.config.hashline_enabled);
2272        assert!(result.config.restrict_to_project_root);
2273        assert_eq!(result.config.semantic.backend, SemanticBackend::Ollama);
2274        assert_eq!(
2275            result.config.semantic.api_key_env.as_deref(),
2276            Some("USER_KEY")
2277        );
2278        assert!(result.config.sandbox.enabled);
2279        assert_eq!(
2280            result.config.sandbox.write_allow,
2281            vec![PathBuf::from("/user/write")]
2282        );
2283        let keys = drop_keys(&result);
2284        for key in [
2285            "restrict_to_project_root",
2286            "semantic.backend",
2287            "semantic.base_url",
2288            "semantic.api_key_env",
2289            "subc",
2290            "sandbox.enabled",
2291            "sandbox.write_allow",
2292        ] {
2293            assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
2294        }
2295    }
2296
2297    #[test]
2298    fn git_co_author_accepts_project_precedence_and_rejects_invalid_identities() {
2299        let resolved = resolve_config(&[
2300            tier("user", r#"{"git":{"co_author":"auto"}}"#),
2301            tier(
2302                "project",
2303                r#"{"git":{"co_author":"Pair Agent <pair@example.test>"}}"#,
2304            ),
2305        ]);
2306        assert_eq!(
2307            resolved.config.git.co_author,
2308            "Pair Agent <pair@example.test>"
2309        );
2310        assert!(resolved.dropped.is_empty());
2311
2312        let invalid = resolve_config(&[tier(
2313            "user",
2314            r#"{"git":{"co_author":"not-an-identity"},"search_index":true}"#,
2315        )]);
2316        assert_eq!(invalid.config.git.co_author, "off");
2317        assert!(invalid.config.search_index);
2318    }
2319
2320    #[test]
2321    fn unknown_edit_mode_warns_and_falls_back_without_dropping_valid_keys() {
2322        let result = resolve_config(&[tier(
2323            "project",
2324            r#"{"edit_mode":"future","format_on_edit":true}"#,
2325        )]);
2326
2327        assert!(!result.config.hashline_enabled);
2328        assert!(result.config.format_on_edit);
2329        assert_eq!(result.warnings.len(), 1);
2330        assert_eq!(result.warnings[0].code, "invalid_edit_mode");
2331        assert_eq!(result.warnings[0].key, "edit_mode");
2332        assert_eq!(result.warnings[0].tier, "project");
2333        assert_eq!(result.warnings[0].value, "future");
2334    }
2335
2336    #[test]
2337    fn synapse_requires_explicit_model_and_receives_user_subc_connection_file() {
2338        let without_model = resolve_config(&[tier(
2339            "user",
2340            r#"{
2341                "semantic": { "backend": "synapse" },
2342                "subc": { "connection_file": "/tmp/subc-connection.json" }
2343            }"#,
2344        )]);
2345        assert_eq!(
2346            without_model.config.semantic.backend,
2347            SemanticBackend::Synapse
2348        );
2349        assert!(without_model.config.semantic.model.is_empty());
2350        assert_eq!(
2351            without_model.config.semantic.subc_connection_file,
2352            Some(PathBuf::from("/tmp/subc-connection.json"))
2353        );
2354
2355        let with_model = resolve_config(&[tier(
2356            "user",
2357            r#"{
2358                "semantic": { "backend": "synapse", "model": "configured-model" },
2359                "subc": { "connection_file": "/tmp/subc-connection.json" }
2360            }"#,
2361        )]);
2362        assert_eq!(with_model.config.semantic.model, "configured-model");
2363    }
2364
2365    #[test]
2366    fn semantic_query_timeout_clamps_to_interactive_budget_range() {
2367        let below_min =
2368            resolve_config(&[tier("user", r#"{ "semantic": { "query_timeout_ms": 1 } }"#)]);
2369        assert_eq!(
2370            below_min.config.semantic.query_timeout_ms,
2371            MIN_SEMANTIC_QUERY_TIMEOUT_MS
2372        );
2373
2374        let above_max = resolve_config(&[tier(
2375            "user",
2376            r#"{ "semantic": { "query_timeout_ms": 50000 } }"#,
2377        )]);
2378        assert_eq!(
2379            above_max.config.semantic.query_timeout_ms,
2380            MAX_SEMANTIC_QUERY_TIMEOUT_MS
2381        );
2382    }
2383
2384    #[test]
2385    fn inspect_diagnostics_timeout_clamps_to_blocking_phase_range() {
2386        let below_min = resolve_config(&[tier(
2387            "user",
2388            r#"{ "inspect": { "diagnostics_timeout_ms": 1 } }"#,
2389        )]);
2390        assert_eq!(
2391            below_min.config.inspect.diagnostics_timeout_ms,
2392            MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS
2393        );
2394
2395        let above_max = resolve_config(&[tier(
2396            "user",
2397            r#"{ "inspect": { "diagnostics_timeout_ms": 700000 } }"#,
2398        )]);
2399        assert_eq!(
2400            above_max.config.inspect.diagnostics_timeout_ms,
2401            MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS
2402        );
2403    }
2404
2405    #[test]
2406    fn project_inspect_diagnostics_timeout_can_raise_but_never_lower_user_value() {
2407        let lower = resolve_config(&[
2408            tier(
2409                "user",
2410                r#"{ "inspect": { "diagnostics_timeout_ms": 180000 } }"#,
2411            ),
2412            tier(
2413                "project",
2414                r#"{ "inspect": { "diagnostics_timeout_ms": 90000 } }"#,
2415            ),
2416        ]);
2417        assert_eq!(lower.config.inspect.diagnostics_timeout_ms, 180_000);
2418
2419        let higher = resolve_config(&[
2420            tier(
2421                "user",
2422                r#"{ "inspect": { "diagnostics_timeout_ms": 180000 } }"#,
2423            ),
2424            tier(
2425                "project",
2426                r#"{ "inspect": { "diagnostics_timeout_ms": 240000 } }"#,
2427            ),
2428        ]);
2429        assert_eq!(higher.config.inspect.diagnostics_timeout_ms, 240_000);
2430    }
2431
2432    #[test]
2433    fn config_resolve_project_allowed_search_index_wins() {
2434        let result = resolve_config(&[
2435            tier("user", r#"{ "search_index": false }"#),
2436            tier("project", r#"{ "search_index": true }"#),
2437        ]);
2438
2439        assert!(result.config.search_index);
2440        assert!(result.dropped.is_empty());
2441    }
2442
2443    #[test]
2444    fn worktree_ram_overlay_resolves_at_user_and_project_tiers() {
2445        assert!(!resolve_config(&[]).config.worktree.ram_overlay);
2446
2447        let user = resolve_config(&[tier("user", r#"{ "worktree": { "ram_overlay": true } }"#)]);
2448        assert!(user.config.worktree.ram_overlay);
2449        assert!(user.dropped.is_empty());
2450
2451        let project = resolve_config(&[
2452            tier("user", r#"{ "worktree": { "ram_overlay": false } }"#),
2453            tier("project", r#"{ "worktree": { "ram_overlay": true } }"#),
2454        ]);
2455        assert!(project.config.worktree.ram_overlay);
2456        assert!(project.dropped.is_empty());
2457    }
2458
2459    #[test]
2460    fn project_sandbox_can_add_read_denies_but_not_enable_or_add_writes() {
2461        let result = resolve_config(&[
2462            tier(
2463                "user",
2464                r#"{
2465                  "sandbox": {
2466                    "enabled": true,
2467                    "write_allow": ["/user/write"],
2468                    "read_deny": ["/user/secret"]
2469                  }
2470                }"#,
2471            ),
2472            tier(
2473                "project",
2474                r#"{
2475                  "sandbox": {
2476                    "enabled": false,
2477                    "write_allow": ["/project/write"],
2478                    "read_deny": ["/project/secret", "/user/secret"]
2479                  }
2480                }"#,
2481            ),
2482        ]);
2483
2484        assert!(result.config.sandbox.enabled);
2485        assert_eq!(
2486            result.config.sandbox.write_allow,
2487            vec![PathBuf::from("/user/write")]
2488        );
2489        assert_eq!(
2490            result.config.sandbox.read_deny,
2491            vec![
2492                PathBuf::from("/user/secret"),
2493                PathBuf::from("/project/secret")
2494            ]
2495        );
2496        assert_eq!(
2497            drop_keys(&result),
2498            vec![
2499                "sandbox.enabled".to_string(),
2500                "sandbox.write_allow".to_string()
2501            ]
2502        );
2503    }
2504
2505    #[test]
2506    fn project_can_enable_sandbox_but_never_disable_it() {
2507        // Hardening is one-way: a repo may opt itself into kernel confinement.
2508        let opt_in = resolve_config(&[
2509            tier("user", r#"{}"#),
2510            tier("project", r#"{ "sandbox": { "enabled": true } }"#),
2511        ]);
2512        assert!(opt_in.config.sandbox.enabled);
2513        assert!(
2514            !drop_keys(&opt_in).contains(&"sandbox.enabled".to_string()),
2515            "project enabled:true is an accepted opt-in, not a dropped key"
2516        );
2517
2518        // The weakening direction stays user-only: project false cannot win.
2519        let opt_out = resolve_config(&[
2520            tier("user", r#"{ "sandbox": { "enabled": true } }"#),
2521            tier("project", r#"{ "sandbox": { "enabled": false } }"#),
2522        ]);
2523        assert!(opt_out.config.sandbox.enabled);
2524        assert!(drop_keys(&opt_out).contains(&"sandbox.enabled".to_string()));
2525    }
2526
2527    #[test]
2528    fn config_resolve_project_user_only_keys_are_dropped_and_user_values_win() {
2529        let result = resolve_config(&[
2530            tier(
2531                "user",
2532                r#"{
2533                  "restrict_to_project_root": true,
2534                  "url_fetch_allow_private": true,
2535                  "formatter_timeout_secs": 11,
2536                  "type_checker_timeout_secs": 33,
2537                  "auto_update": true,
2538                  "bridge": { "request_timeout_ms": 3000, "hang_threshold": 3 },
2539                  "semantic": {
2540                    "backend": "openai_compatible",
2541                    "base_url": "https://user.example.test",
2542                    "api_key_env": "USER_KEY",
2543                    "model": "user-model",
2544                    "query_timeout_ms": 900
2545                  },
2546                  "lsp": {
2547                    "servers": {
2548                      "rust": { "extensions": [".rs"], "binary": "rust-analyzer" }
2549                    },
2550                    "disabled": ["user-disabled"],
2551                    "versions": { "typescript-language-server": "1.0.0" },
2552                    "auto_install": true,
2553                    "grace_days": 7
2554                  }
2555                }"#,
2556            ),
2557            tier(
2558                "project",
2559                r#"{
2560                  "restrict_to_project_root": false,
2561                  "url_fetch_allow_private": false,
2562                  "formatter_timeout_secs": 22,
2563                  "type_checker_timeout_secs": 44,
2564                  "auto_update": false,
2565                  "bridge": { "request_timeout_ms": 4000, "hang_threshold": 4 },
2566                  "semantic": {
2567                    "backend": "ollama",
2568                    "base_url": "https://project.example.test",
2569                    "api_key_env": "PROJECT_KEY",
2570                    "model": "project-model",
2571                    "timeout_ms": 2222,
2572                    "query_timeout_ms": 2222
2573                  },
2574                  "lsp": {
2575                    "servers": {
2576                      "rust": { "extensions": [".evil"], "binary": "evil-lsp" }
2577                    },
2578                    "disabled": ["project-disabled"],
2579                    "versions": { "evil-lsp": "9.9.9" },
2580                    "auto_install": false,
2581                    "grace_days": 1,
2582                    "python": "ty"
2583                  }
2584                }"#,
2585            ),
2586        ]);
2587
2588        assert!(result.config.restrict_to_project_root);
2589        assert!(result.config.url_fetch_allow_private);
2590        assert_eq!(result.config.formatter_timeout_secs, 11);
2591        assert_eq!(result.config.type_checker_timeout_secs, 33);
2592        assert_eq!(
2593            result.config.semantic.backend,
2594            SemanticBackend::OpenAiCompatible
2595        );
2596        assert_eq!(
2597            result.config.semantic.base_url.as_deref(),
2598            Some("https://user.example.test")
2599        );
2600        assert_eq!(
2601            result.config.semantic.api_key_env.as_deref(),
2602            Some("USER_KEY")
2603        );
2604        assert_eq!(result.config.semantic.model, "project-model");
2605        assert_eq!(result.config.semantic.timeout_ms, 2222);
2606        assert_eq!(result.config.semantic.query_timeout_ms, 900);
2607        assert_eq!(result.config.lsp_servers.len(), 1);
2608        assert_eq!(result.config.lsp_servers[0].binary, "rust-analyzer");
2609        assert!(result.config.disabled_lsp.contains("user-disabled"));
2610        assert!(!result.config.disabled_lsp.contains("project-disabled"));
2611        assert!(result.config.disabled_lsp.contains("python"));
2612        assert!(result.config.experimental_lsp_ty);
2613
2614        let keys = drop_keys(&result);
2615        let expected = [
2616            "restrict_to_project_root",
2617            "url_fetch_allow_private",
2618            "formatter_timeout_secs",
2619            "type_checker_timeout_secs",
2620            "auto_update",
2621            "bridge",
2622            "semantic.backend",
2623            "semantic.base_url",
2624            "semantic.api_key_env",
2625            "semantic.query_timeout_ms",
2626            "lsp.servers",
2627            "lsp.versions",
2628            "lsp.auto_install",
2629            "lsp.grace_days",
2630            "lsp.disabled",
2631        ];
2632        for key in expected {
2633            assert!(keys.contains(&key.to_string()), "missing dropped key {key}");
2634        }
2635        assert_eq!(keys.len(), expected.len());
2636        assert!(result
2637            .dropped
2638            .iter()
2639            .all(|dropped| dropped.tier == "project"));
2640    }
2641
2642    #[test]
2643    fn config_resolve_bash_ladder_and_merge_parity() {
2644        let true_result = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
2645        assert!(true_result.config.experimental_bash_rewrite);
2646        assert!(true_result.config.experimental_bash_compress);
2647        assert!(true_result.config.experimental_bash_background);
2648
2649        let false_result = resolve_config(&[tier("user", r#"{ "bash": false }"#)]);
2650        assert!(!false_result.config.experimental_bash_rewrite);
2651        assert!(!false_result.config.experimental_bash_compress);
2652        assert!(!false_result.config.experimental_bash_background);
2653
2654        let object_default_result = resolve_config(&[tier("user", r#"{ "bash": {} }"#)]);
2655        assert!(object_default_result.config.experimental_bash_rewrite);
2656        assert!(object_default_result.config.experimental_bash_compress);
2657        assert!(object_default_result.config.experimental_bash_background);
2658
2659        let object_partial_result =
2660            resolve_config(&[tier("user", r#"{ "bash": { "compress": false } }"#)]);
2661        assert!(object_partial_result.config.experimental_bash_rewrite);
2662        assert!(!object_partial_result.config.experimental_bash_compress);
2663        assert!(object_partial_result.config.experimental_bash_background);
2664
2665        let legacy_result = resolve_config(&[tier(
2666            "user",
2667            r#"{ "experimental": { "bash": { "rewrite": true } } }"#,
2668        )]);
2669        assert!(legacy_result.config.experimental_bash_rewrite);
2670        assert!(!legacy_result.config.experimental_bash_compress);
2671        assert!(!legacy_result.config.experimental_bash_background);
2672
2673        let surface_default_result = resolve_config(&[tier("user", r#"{}"#)]);
2674        assert!(surface_default_result.config.experimental_bash_rewrite);
2675        assert!(surface_default_result.config.experimental_bash_compress);
2676        assert!(surface_default_result.config.experimental_bash_background);
2677
2678        let minimal_surface_result =
2679            resolve_config(&[tier("user", r#"{ "tool_surface": "minimal" }"#)]);
2680        assert!(!minimal_surface_result.config.experimental_bash_rewrite);
2681        assert!(!minimal_surface_result.config.experimental_bash_compress);
2682        assert!(!minimal_surface_result.config.experimental_bash_background);
2683
2684        let merged_result = resolve_config(&[
2685            tier("user", r#"{ "bash": true }"#),
2686            tier("project", r#"{ "bash": { "compress": false } }"#),
2687        ]);
2688        assert!(merged_result.config.experimental_bash_rewrite);
2689        assert!(!merged_result.config.experimental_bash_compress);
2690        assert!(merged_result.config.experimental_bash_background);
2691
2692        let false_then_object_result = resolve_config(&[
2693            tier("user", r#"{ "bash": false }"#),
2694            tier("project", r#"{ "bash": { "compress": true } }"#),
2695        ]);
2696        assert!(!false_then_object_result.config.experimental_bash_rewrite);
2697        assert!(false_then_object_result.config.experimental_bash_compress);
2698        assert!(!false_then_object_result.config.experimental_bash_background);
2699    }
2700
2701    #[test]
2702    fn config_resolve_bash_foreground_wait_clamps_to_floor() {
2703        let Some(raw) = parse_tier(&tier(
2704            "user",
2705            r#"{ "bash": { "foreground_wait_window_ms": 1, "subagent_background": true } }"#,
2706        )) else {
2707            panic!("test tier should parse");
2708        };
2709        let bash = resolve_bash_config(&raw);
2710
2711        assert_eq!(
2712            bash.foreground_wait_window_ms,
2713            FOREGROUND_WAIT_WINDOW_MIN_MS
2714        );
2715        assert!(bash.subagent_background);
2716
2717        let result = resolve_config(&[tier(
2718            "user",
2719            r#"{ "bash": { "foreground_wait_window_ms": 1 } }"#,
2720        )]);
2721        assert_eq!(
2722            result.config.foreground_wait_window_ms,
2723            FOREGROUND_WAIT_WINDOW_MIN_MS
2724        );
2725
2726        // Unset → the default wait-window (matches the plugin's
2727        // FOREGROUND_WAIT_WINDOW_DEFAULT_MS = 15_000). This locks the default,
2728        // not just the floor, so a future edit can't silently shorten the
2729        // server-side promotion window once the plugin orchestrates through Rust.
2730        let defaulted = resolve_config(&[tier("user", r#"{ "bash": true }"#)]);
2731        assert_eq!(
2732            defaulted.config.foreground_wait_window_ms,
2733            FOREGROUND_WAIT_WINDOW_DEFAULT_MS
2734        );
2735        assert_eq!(FOREGROUND_WAIT_WINDOW_DEFAULT_MS, 15_000);
2736    }
2737
2738    #[test]
2739    fn config_resolve_partial_parse_drops_invalid_section_and_keeps_valid_sections() {
2740        let result = resolve_config(&[tier(
2741            "user",
2742            r#"{
2743              "semantic": { "timeout_ms": 0 },
2744              "search_index": true,
2745              "format_on_edit": false
2746            }"#,
2747        )]);
2748
2749        assert!(result.config.search_index);
2750        assert!(!result.config.format_on_edit);
2751        assert_eq!(result.config.semantic, SemanticBackendConfig::default());
2752        assert!(result.dropped.is_empty());
2753    }
2754
2755    #[test]
2756    fn config_resolve_unknown_top_level_key_is_dropped_but_rest_survives() {
2757        let result = resolve_config(&[tier(
2758            "user",
2759            r#"{ "not_a_real_key": true, "search_index": true }"#,
2760        )]);
2761
2762        assert!(result.config.search_index);
2763        assert!(result.dropped.is_empty());
2764    }
2765
2766    #[test]
2767    fn resolve_config_onto_resets_core_fields_no_cross_bind_inheritance() {
2768        // Cross-bind escalation regression: a first (trusted) bind sets a
2769        // capability field; a second bind that omits it must NOT inherit it.
2770        // This is the configure-path entry (seeded from a prior config), where
2771        // reset-onto-default — unlike the old overlay — makes the resolved core
2772        // config a pure function of the CURRENT bind's tiers.
2773        let mut config = Config::default();
2774
2775        // Bind 1 (trusted user tier) sets url_fetch_allow_private + a custom LSP
2776        // server + restrict_to_project_root.
2777        let dropped1 = resolve_config_onto(
2778            &[tier(
2779                "user",
2780                r#"{
2781                  "url_fetch_allow_private": true,
2782                  "restrict_to_project_root": true,
2783                  "lsp": { "servers": { "rust": { "extensions": [".rs"], "binary": "rust-analyzer" } } }
2784                }"#,
2785            )],
2786            &mut config,
2787        );
2788        assert!(dropped1.is_empty());
2789        assert!(config.url_fetch_allow_private);
2790        assert!(config.restrict_to_project_root);
2791        assert_eq!(config.lsp_servers.len(), 1);
2792
2793        // Bind 2 omits all three. With reset semantics they return to DEFAULT —
2794        // the second bind cannot inherit the first bind's capabilities.
2795        let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2796        assert!(
2797            !config.url_fetch_allow_private,
2798            "url_fetch_allow_private must reset to default, not inherit prior bind"
2799        );
2800        assert!(
2801            !config.restrict_to_project_root,
2802            "restrict_to_project_root must reset to default"
2803        );
2804        assert!(
2805            config.lsp_servers.is_empty(),
2806            "lsp_servers must reset to default, not inherit prior bind's custom server"
2807        );
2808        assert!(config.search_index, "this bind's own field still applies");
2809    }
2810
2811    #[test]
2812    fn resolve_config_onto_empty_tiers_resets_to_default() {
2813        // The empty-tier path must still reset (it routes through the same
2814        // always-run resolution in handle_configure). A bind with no tiers after
2815        // a privileged bind must drop the privileged config.
2816        let mut config = Config::default();
2817        let _ = resolve_config_onto(
2818            &[tier("user", r#"{ "url_fetch_allow_private": true }"#)],
2819            &mut config,
2820        );
2821        assert!(config.url_fetch_allow_private);
2822
2823        let _ = resolve_config_onto(&[], &mut config);
2824        assert!(
2825            !config.url_fetch_allow_private,
2826            "empty-tier bind must reset core config to default"
2827        );
2828    }
2829
2830    #[test]
2831    fn resolve_config_onto_preserves_process_state_fields() {
2832        // Process-state fields (not part of RawAftConfig) are carried across the
2833        // reset so plugin-mode behavior is unchanged (the plugin re-sends them
2834        // via flat configure params right after this call).
2835        let mut config = Config {
2836            storage_dir: Some(std::path::PathBuf::from("/tmp/aft-store")),
2837            lsp_paths_extra: vec![std::path::PathBuf::from("/tmp/lsp-bin")],
2838            bash_permissions: true,
2839            project_root: Some(std::path::PathBuf::from("/tmp/proj")),
2840            ..Default::default()
2841        };
2842
2843        let _ = resolve_config_onto(&[tier("user", r#"{ "search_index": true }"#)], &mut config);
2844
2845        assert_eq!(
2846            config.storage_dir,
2847            Some(std::path::PathBuf::from("/tmp/aft-store"))
2848        );
2849        assert_eq!(
2850            config.lsp_paths_extra,
2851            vec![std::path::PathBuf::from("/tmp/lsp-bin")]
2852        );
2853        assert!(config.bash_permissions);
2854        assert_eq!(
2855            config.project_root,
2856            Some(std::path::PathBuf::from("/tmp/proj"))
2857        );
2858        assert!(config.search_index);
2859    }
2860
2861    #[test]
2862    fn index_roots_are_user_only_normalized_and_validate_before_resolution() {
2863        let result = resolve_config(&[tier(
2864            "user",
2865            r#"{
2866              "index": {
2867                "roots": [{
2868                  "path": "~/.aft-standing-root",
2869                  "indexes": ["semantic", "callgraph"],
2870                  "future_field": true
2871                }]
2872              }
2873            }"#,
2874        )]);
2875        assert_eq!(result.config.index.roots.len(), 1);
2876        assert_eq!(result.config.index.roots[0].path, "~/.aft-standing-root");
2877        assert_eq!(
2878            result.config.index.roots[0].indexes,
2879            vec![IndexKind::Search, IndexKind::Semantic, IndexKind::Callgraph]
2880        );
2881        assert!(result
2882            .warnings
2883            .iter()
2884            .any(|warning| warning.code == "index_dependency_closure"));
2885        assert!(result
2886            .warnings
2887            .iter()
2888            .any(|warning| warning.code == "unknown_index_root_field"));
2889
2890        for invalid in [
2891            r#"{ "index": { "roots": [{ "path": "relative", "indexes": ["search"] }] } }"#,
2892            r#"{ "index": { "roots": [{ "path": "~/a", "indexes": [] }] } }"#,
2893            r#"{ "index": { "roots": [{ "path": "~/a", "indexes": ["search", "search"] }] } }"#,
2894            r#"{ "index": { "roots": [{ "path": "~/a", "indexes": ["unknown"] }] } }"#,
2895        ] {
2896            let invalid = resolve_config(&[tier("user", invalid)]);
2897            assert!(invalid.config.index.roots.is_empty());
2898            let warning = invalid
2899                .warnings
2900                .iter()
2901                .find(|warning| warning.code == "invalid_index_roots")
2902                .expect("invalid standing roots must be named");
2903            assert!(warning.message.contains("index.roots"));
2904        }
2905    }
2906
2907    #[test]
2908    fn index_roots_project_and_mcp_tiers_are_rejected_at_the_trust_boundary() {
2909        let result = resolve_config(&[
2910            tier(
2911                "user",
2912                r#"{ "index": { "roots": [{ "path": "~/user", "indexes": ["search"] }] } }"#,
2913            ),
2914            tier(
2915                "project",
2916                r#"{ "index": { "roots": [{ "path": "~/project", "indexes": ["semantic"] }] } }"#,
2917            ),
2918            tier(
2919                "mcp:untrusted",
2920                r#"{ "index": { "roots": [{ "path": "~/mcp", "indexes": ["callgraph"] }] } }"#,
2921            ),
2922        ]);
2923        assert_eq!(result.config.index.roots[0].path, "~/user");
2924        assert_eq!(
2925            result
2926                .dropped
2927                .iter()
2928                .filter(|dropped| dropped.key == "index.roots")
2929                .map(|dropped| dropped.tier.as_str())
2930                .collect::<Vec<_>>(),
2931            vec!["project", "mcp:untrusted"]
2932        );
2933    }
2934
2935    #[test]
2936    fn config_resolve_jsonc_comments_and_trailing_commas_parse() {
2937        let result = resolve_config(&[tier(
2938            "user",
2939            r#"{
2940              // line comment
2941              "search_index": true,
2942              "formatter": {
2943                "rust": "rustfmt", /* block comment */
2944              },
2945            }"#,
2946        )]);
2947
2948        assert!(result.config.search_index);
2949        assert_eq!(
2950            result.config.formatter.get("rust").map(String::as_str),
2951            Some("rustfmt")
2952        );
2953    }
2954}