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