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