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