Skip to main content

lean_ctx/core/config/
loader.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Config path resolution and disk loading.
4
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use super::{Config, ConfigCacheSlot, default_shell_allowlist};
9
10const CONFIG_PROFILE_ENV: &str = "LEAN_CTX_CONFIG_PROFILE";
11
12thread_local! {
13    /// Set while the shell-security policy is merged: no environment-selected
14    /// profile applies to it (`shell_policy`).
15    static IGNORE_ENV_PROFILE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
16}
17
18/// Runs `f` with the environment's config profile ignored.
19pub(super) fn without_env_profile<T>(f: impl FnOnce() -> T) -> T {
20    let previous = IGNORE_ENV_PROFILE.with(|c| c.replace(true));
21    let out = f();
22    IGNORE_ENV_PROFILE.with(|c| c.set(previous));
23    out
24}
25
26pub(super) fn environment_config_profile() -> Option<String> {
27    if IGNORE_ENV_PROFILE.with(std::cell::Cell::get) {
28        return None;
29    }
30    std::env::var(CONFIG_PROFILE_ENV)
31        .ok()
32        .map(|name| name.trim().to_string())
33        .filter(|name| !name.is_empty())
34}
35
36/// Parses a config and recursively applies one named partial overlay. An
37/// explicit selector (normally the environment) wins over `config_profile`.
38pub(super) fn parse_config_with_profile(
39    raw: &str,
40    explicit_profile: Option<&str>,
41) -> Result<Config, String> {
42    resolve_config_table(raw, explicit_profile)?
43        .try_into()
44        .map_err(|error: toml::de::Error| error.to_string())
45}
46
47/// The TOML document `raw` describes once its selected profile overlay is
48/// applied (and legacy compression keys are migrated), before it becomes a
49/// [`Config`]: a merge can see which keys the document sets.
50pub(super) fn resolve_config_table(
51    raw: &str,
52    explicit_profile: Option<&str>,
53) -> Result<toml::Value, String> {
54    let migrated = Config::migrate_v3_compression_document(raw)?;
55    let raw = migrated.as_deref().unwrap_or(raw);
56    let mut value: toml::Value = toml::from_str(raw).map_err(|error| error.to_string())?;
57    super::renamed::apply(
58        value
59            .as_table_mut()
60            .expect("a TOML document always has a root table"),
61    );
62    let configured_profile = value.get("config_profile").and_then(toml::Value::as_str);
63    let selected = explicit_profile
64        .map(str::trim)
65        .filter(|name| !name.is_empty())
66        .or(configured_profile);
67
68    if let Some(name) = selected {
69        let profiles = value
70            .get("profiles")
71            .and_then(toml::Value::as_table)
72            .ok_or_else(|| format!("config profile '{name}' selected but [profiles] is missing"))?;
73        let mut overlay = profiles
74            .get(name)
75            .and_then(toml::Value::as_table)
76            .cloned()
77            .ok_or_else(|| format!("config profile '{name}' is not defined"))?;
78        if overlay.remove("profiles").is_some() || overlay.remove("config_profile").is_some() {
79            return Err(format!(
80                "config profile '{name}' cannot override reserved profile keys"
81            ));
82        }
83        super::renamed::apply(&mut overlay);
84        merge_toml_tables(
85            value
86                .as_table_mut()
87                .expect("a TOML document always has a root table"),
88            overlay,
89        );
90    }
91
92    Ok(value)
93}
94
95fn merge_toml_tables(base: &mut toml::Table, overlay: toml::Table) {
96    for (key, overlay_value) in overlay {
97        match (base.get_mut(&key), overlay_value) {
98            (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
99                merge_toml_tables(base_table, overlay_table);
100            }
101            (_, replacement) => {
102                base.insert(key, replacement);
103            }
104        }
105    }
106}
107
108/// Holds the most recent global `config.toml` parse error, if the file currently
109/// fails to parse. When that happens `Config::load()` silently falls back to the
110/// built-in defaults and only logs to stderr — which is invisible over an MCP/stdio
111/// transport. Recording it here lets callers (e.g. the shell-allowlist diagnostic
112/// and `lean-ctx doctor`) surface "you're on defaults because your config is broken".
113static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
114
115/// Returns the most recent global config parse error, or `None` if the current
116/// `config.toml` parsed successfully (or no config file exists).
117#[must_use]
118pub fn last_config_parse_error() -> Option<String> {
119    LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
120}
121
122fn record_parse_error(err: Option<String>) {
123    if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
124        *guard = err;
125    }
126}
127
128/// Why a loaded [`Config`] holds built-in defaults although a global
129/// `config.toml` is present. Carried on the snapshot itself
130/// ([`Config::load_failure`]), so a decision that depends on "were the
131/// operator's settings applied?" reads it from the very value it uses.
132/// [`last_config_parse_error`] is a diagnostic for humans and must not be used
133/// for that: it is process-global and a later load overwrites it.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum ConfigLoadFailure {
136    /// The file exists but could not be read as text: permission denied,
137    /// invalid UTF-8, a directory in its place, or another I/O error. A file
138    /// that is simply absent (clean install) is not a failure.
139    Unreadable,
140    /// The file was read but is not a valid configuration: TOML syntax, a
141    /// field of the wrong type, or a selected profile that is not defined.
142    Invalid,
143}
144
145impl ConfigLoadFailure {
146    /// Stable reason code for refusals and diagnostics.
147    #[must_use]
148    pub fn code(self) -> &'static str {
149        match self {
150            Self::Unreadable => "config.global_unreadable",
151            Self::Invalid => "config.global_invalid",
152        }
153    }
154}
155
156/// Reset every SECURITY-sensitive field of a parsed project-local `Config` back
157/// to its default, returning the names of the ones that actually carried an
158/// override. Used by [`Config::merge_local`] for untrusted workspaces: clearing a
159/// field to its default makes the downstream "== default ⇒ no override" merge
160/// guards skip it automatically, so a single list here gates every sensitive key
161/// without touching the per-field merge arms (security audit #4).
162///
163/// Sensitive = anything that can widen lean-ctx's own boundaries or steer the
164/// agent: the shell allowlist, path-jail roots, proxy upstreams, command
165/// aliases, network passthrough, rules scope/injection, tool surface control
166/// (profile/enabled-list/categories, disabling) and permission inheritance.
167/// Comfort/perf knobs are intentionally NOT listed.
168pub(crate) fn strip_sensitive_overrides(local: &mut Config) -> Vec<&'static str> {
169    let mut withheld: Vec<&'static str> = Vec::new();
170
171    if local.shell_allowlist != default_shell_allowlist() {
172        local.shell_allowlist = default_shell_allowlist();
173        withheld.push("shell_allowlist");
174    }
175    if !local.shell_allowlist_extra.is_empty() {
176        local.shell_allowlist_extra.clear();
177        withheld.push("shell_allowlist_extra");
178    }
179    if !local.allow_paths.is_empty() {
180        local.allow_paths.clear();
181        withheld.push("allow_paths");
182    }
183    if !local.extra_roots.is_empty() {
184        local.extra_roots.clear();
185        withheld.push("extra_roots");
186    }
187    // Shell-redirect write targets widen what lean-ctx may write.
188    if !local.write_allow_paths.is_empty() {
189        local.write_allow_paths.clear();
190        withheld.push("write_allow_paths");
191    }
192    // Read-only roots also resolve reads like extra roots (#475).
193    if !local.read_only_roots.is_empty() {
194        local.read_only_roots.clear();
195        withheld.push("read_only_roots");
196    }
197    // `off` skips the native-read redirect and the redaction it applies.
198    if local.read_redirect == super::ReadRedirect::Off {
199        local.read_redirect = super::ReadRedirect::default();
200        withheld.push("read_redirect");
201    }
202    if !local.allow_symlink_roots.is_empty() {
203        local.allow_symlink_roots.clear();
204        withheld.push("allow_symlink_roots");
205    }
206    // Destinations ctx_shell redirects and downloads may write to.
207    if !local.write_allow_paths.is_empty() {
208        local.write_allow_paths.clear();
209        withheld.push("write_allow_paths");
210    }
211    if !local.custom_aliases.is_empty() {
212        local.custom_aliases.clear();
213        withheld.push("custom_aliases");
214    }
215    if !local.passthrough_urls.is_empty() {
216        local.passthrough_urls.clear();
217        withheld.push("passthrough_urls");
218    }
219    // Paths in `redirect_exclude` skip the native-read redirect, and with it
220    // the redaction that path applies; a cloned repo must not opt itself out.
221    if !local.redirect_exclude.is_empty() {
222        local.redirect_exclude.clear();
223        withheld.push("redirect_exclude");
224    }
225    if local.proxy.anthropic_upstream.is_some()
226        || local.proxy.openai_upstream.is_some()
227        || local.proxy.chatgpt_upstream.is_some()
228        || local.proxy.gemini_upstream.is_some()
229    {
230        local.proxy.anthropic_upstream = None;
231        local.proxy.openai_upstream = None;
232        local.proxy.chatgpt_upstream = None;
233        local.proxy.gemini_upstream = None;
234        withheld.push("proxy.*_upstream");
235    }
236    // `eager` lets background work start language servers, which execute
237    // project code (build scripts, proc macros). Lowering to off/auto is safe.
238    if local.semantic_mode == super::SemanticMode::Eager {
239        local.semantic_mode = super::SemanticMode::default();
240        withheld.push("semantic_mode");
241    }
242    if local.rules_scope.is_some() {
243        local.rules_scope = None;
244        withheld.push("rules_scope");
245    }
246    if local.rules_injection.is_some() {
247        local.rules_injection = None;
248        withheld.push("rules_injection");
249    }
250    if local.permission_inheritance.is_some() {
251        local.permission_inheritance = None;
252        withheld.push("permission_inheritance");
253    }
254    if !local.disabled_tools.is_empty() {
255        local.disabled_tools.clear();
256        withheld.push("disabled_tools");
257    }
258    if local.tool_profile.is_some() {
259        local.tool_profile = None;
260        withheld.push("tool_profile");
261    }
262    if !local.tools_enabled.is_empty() {
263        local.tools_enabled.clear();
264        withheld.push("tools_enabled");
265    }
266    if !local.default_tool_categories.is_empty() {
267        local.default_tool_categories.clear();
268        withheld.push("default_tool_categories");
269    }
270    if !local.index.respect_gitignore {
271        local.index.respect_gitignore = true;
272        withheld.push("index.respect_gitignore");
273    }
274    if !local.shell_allowlist_subcommand_scoping {
275        local.shell_allowlist_subcommand_scoping = true;
276        withheld.push("shell_allowlist_subcommand_scoping");
277    }
278
279    withheld
280}
281
282/// Tell the gateway evidence store the ceiling this configuration gives.
283/// The store's writers run deep inside calls and must not load a
284/// configuration themselves (`receipt_store::capacity`, G09).
285fn note_gateway_store_ceiling(cfg: &Config) {
286    crate::core::context_admission::receipt_store::capacity::note_configured_ceiling(
287        cfg.gateway_store_configured_mb(),
288    );
289}
290
291/// Names of the SECURITY-sensitive overrides a project-local `.lean-ctx.toml`
292/// carries — the keys `strip_sensitive_overrides` would withhold for an
293/// untrusted workspace. Read-only (parses a throwaway `Config`); used by
294/// `lean-ctx trust` to tell the user exactly what trusting will enable.
295#[must_use]
296pub fn local_sensitive_overrides(local_toml: &str) -> Vec<&'static str> {
297    let selected = environment_config_profile();
298    match parse_config_with_profile(local_toml, selected.as_deref()) {
299        Ok(mut parsed) => strip_sensitive_overrides(&mut parsed),
300        Err(_) => Vec::new(),
301    }
302}
303
304/// A string-array key from the raw GLOBAL config table — never the merged
305/// runtime view, so project-local or default values are never persisted
306/// globally.
307pub fn global_string_list(key: &str) -> Vec<String> {
308    let Some(path) = Config::path() else {
309        return Vec::new();
310    };
311    let Ok(raw) = std::fs::read_to_string(&path) else {
312        return Vec::new();
313    };
314    let Ok(table) = raw.parse::<toml::Table>() else {
315        return Vec::new();
316    };
317    table
318        .get(key)
319        .and_then(toml::Value::as_array)
320        .map(|arr| {
321            arr.iter()
322                .filter_map(|v| v.as_str().map(str::to_string))
323                .collect()
324        })
325        .unwrap_or_default()
326}
327
328impl Config {
329    /// Returns the path to the global config file (`$XDG_CONFIG_HOME/lean-ctx/config.toml`).
330    ///
331    /// Resolves the canonical config category without creating directories or
332    /// repairing permissions; existing single-dir installations retain their path.
333    pub fn path() -> Option<PathBuf> {
334        crate::core::paths::config_dir_read_only()
335            .ok()
336            .map(|d| d.join("config.toml"))
337    }
338
339    /// `Some(path)` when the global config the runtime *resolves* does not exist,
340    /// so lean-ctx is silently on built-in defaults. `None` when a config file is
341    /// present (or HOME is unresolvable).
342    ///
343    /// The directory is layout-dependent (XDG `~/.config/lean-ctx` vs legacy
344    /// `~/.lean-ctx` vs `$LEAN_CTX_DATA_DIR`) and an MCP client may launch the
345    /// server in a sandbox/container with a different `$HOME`. An edit made to a
346    /// *different* `config.toml` than this one is silently ignored; the block
347    /// messages use this to say so out loud over MCP, where the stderr path is
348    /// invisible (#540).
349    #[must_use]
350    pub fn missing_config_path() -> Option<PathBuf> {
351        match Self::path() {
352            Some(p) if !p.exists() => Some(p),
353            _ => None,
354        }
355    }
356
357    /// Returns the path to the project-local config override file.
358    pub fn local_path(project_root: &str) -> PathBuf {
359        PathBuf::from(project_root).join(".lean-ctx.toml")
360    }
361
362    /// Resolves the active project root (env override → session → git toplevel →
363    /// cwd), cached for the process. Exposed crate-wide so workspace-trust and the
364    /// CLI agree with config loading on *which* directory a `.lean-ctx.toml`
365    /// belongs to (GH security audit, finding 4).
366    ///
367    /// Inside a request the request's bound project is the root, uncached: a
368    /// server serving several projects must apply each one's own
369    /// `.lean-ctx.toml` and trust, never the first one it happened to resolve
370    /// (AP04-G3).
371    pub fn find_project_root() -> Option<String> {
372        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
373        if let Ok(project) =
374            crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone())
375        {
376            return project.map(|root| root.to_string_lossy().into_owned());
377        }
378        ROOT_CACHE
379            .get_or_init(Self::find_project_root_inner)
380            .clone()
381    }
382
383    fn find_project_root_inner() -> Option<String> {
384        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
385            && !env_root.is_empty()
386        {
387            return Some(env_root);
388        }
389
390        let cwd = std::env::current_dir().ok();
391
392        if let Some(root) =
393            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
394        {
395            let root_path = std::path::Path::new(&root);
396            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
397            // Route the marker probe through the TCC-guarded helper and never
398            // adopt a ~/Documents project root from a launchd-standalone process
399            // (#356): doing so would later stat its `.lean-ctx.toml`/markers and
400            // pop the macOS privacy prompt in lean-ctx's own name.
401            let has_marker = crate::core::pathutil::has_project_marker(root_path);
402
403            if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
404            {
405                return Some(root);
406            }
407        }
408
409        if let Some(ref cwd) = cwd {
410            // A launchd-standalone process must not shell out to `git` (which
411            // stats the working tree) or adopt cwd as the project root when cwd
412            // is under a TCC-protected dir (#356).
413            let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
414            let git_root = if may_probe_cwd {
415                std::process::Command::new("git")
416                    .args(["rev-parse", "--show-toplevel"])
417                    .current_dir(cwd)
418                    .stdout(std::process::Stdio::piped())
419                    .stderr(std::process::Stdio::null())
420                    .output()
421                    .ok()
422                    .and_then(|o| {
423                        if o.status.success() {
424                            String::from_utf8(o.stdout)
425                                .ok()
426                                .map(|s| s.trim().to_string())
427                        } else {
428                            None
429                        }
430                    })
431            } else {
432                None
433            };
434            if let Some(root) = git_root {
435                return Some(root);
436            }
437            if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
438                return Some(cwd.to_string_lossy().to_string());
439            }
440        }
441        None
442    }
443
444    /// Loads config from disk with caching, merging global + project-local overrides.
445    ///
446    /// The cache is keyed on a **content hash** of the global + project-local
447    /// files, not their mtime. mtime-only invalidation silently served a stale
448    /// `Config` whenever a content edit preserved the mtime (coarse filesystem
449    /// mtime resolution, `cp -p`, atomic save-then-rename, two edits within the
450    /// same second). A long-lived MCP server then kept the old value (e.g.
451    /// `path_jail`) while a fresh `lean-ctx doctor` process — with an empty
452    /// cache — saw the new one (#406). Config files are tiny, so reading +
453    /// hashing them on every load is negligible and guarantees liveness.
454    pub fn load() -> Self {
455        (*Self::load_arc()).clone()
456    }
457
458    /// Shared-ownership variant of [`load`](Self::load): returns the cached
459    /// `Arc<Config>` so the per-dispatch hot path bumps a refcount instead of
460    /// deep-cloning the whole struct. Liveness is identical to `load` — the
461    /// global and project-local files are still read and content-hashed on
462    /// every call (#406); only the cache payload became an `Arc`, so a cache
463    /// hit is a cheap `Arc::clone`.
464    pub fn load_arc() -> Arc<Self> {
465        static CACHE: Mutex<ConfigCacheSlot> = Mutex::new(None);
466        Self::load_arc_with(Self::find_project_root().as_deref(), &CACHE)
467    }
468
469    pub(super) fn load_arc_with(
470        project_root: Option<&str>,
471        cache: &Mutex<ConfigCacheSlot>,
472    ) -> Arc<Self> {
473        let Some(path) = Self::path() else {
474            return Arc::new(Self::default());
475        };
476
477        let local_path = project_root.map(Self::local_path);
478
479        // Read raw content up front so the cache key is a content hash. Only a
480        // file that is absent counts as "no config"; any other read error
481        // means settings exist that could not be applied.
482        let (global_content, unreadable) = match std::fs::read_to_string(&path) {
483            Ok(content) => (Some(content), None),
484            Err(error) if error.kind() == std::io::ErrorKind::NotFound => (None, None),
485            Err(error) => (None, Some(error.kind())),
486        };
487        // TCC (#356): never read a project-local `.lean-ctx.toml` under
488        // ~/Documents from a launchd-standalone process — the read pops the
489        // macOS privacy prompt. `find_project_root` already avoids returning
490        // such roots; this also guards the explicit `LEAN_CTX_PROJECT_ROOT` path.
491        let local_content = local_path
492            .as_ref()
493            .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
494            .and_then(|p| std::fs::read_to_string(p).ok());
495
496        // An unreadable file gets its own cache key, distinct from "absent".
497        let global_hash = global_content
498            .as_deref()
499            .map(crate::core::hasher::hash_str)
500            .or_else(|| unreadable.map(|kind| format!("unreadable:{kind:?}")));
501        let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
502        // Trust is per project: equal local files of two projects are not one
503        // cache entry.
504        let local_key = local_hash
505            .as_deref()
506            .map(|hash| format!("{}\0{hash}", project_root.unwrap_or_default()));
507        let selected_profile = environment_config_profile();
508
509        // Finding 4: a project-local `.lean-ctx.toml`'s SECURITY-sensitive
510        // overrides (shell allowlist, path-jail widening, proxy upstream, …)
511        // are honoured only for a workspace the user has explicitly trusted.
512        // `local_hash` is exactly the content hash workspace-trust pins, so
513        // editing the file after trust re-gates it (see `workspace_trust`).
514        // Decided before the cache lookup and part of the key, so `trust` /
515        // `untrust` reach a running server without a file change (#2144).
516        let trusted = local_content.is_some()
517            && project_root.is_some_and(|r| {
518                crate::core::workspace_trust::is_trusted_for(
519                    std::path::Path::new(r),
520                    local_hash.as_deref().unwrap_or_default(),
521                )
522            });
523
524        if let Ok(guard) = cache.lock()
525            && let Some((
526                ref cfg,
527                ref cached_global,
528                ref cached_local,
529                ref cached_profile,
530                cached_trusted,
531            )) = *guard
532            && *cached_global == global_hash
533            && *cached_local == local_key
534            && *cached_profile == selected_profile
535            && cached_trusted == trusted
536        {
537            note_gateway_store_ceiling(cfg);
538            return Arc::clone(cfg);
539        }
540
541        let mut failure = unreadable.map(|_| ConfigLoadFailure::Unreadable);
542        let mut cfg: Config = if let Some(ref content) = global_content {
543            match parse_config_with_profile(content, selected_profile.as_deref()) {
544                Ok(c) => {
545                    record_parse_error(None);
546                    c
547                }
548                Err(e) => {
549                    failure = Some(ConfigLoadFailure::Invalid);
550                    record_parse_error(Some(e.clone()));
551                    tracing::warn!("config parse error in {}: {e}", path.display());
552                    eprintln!(
553                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
554                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
555                        path.display()
556                    );
557                    Self::default()
558                }
559            }
560        } else if let Some(kind) = unreadable {
561            let message = format!("cannot read config: {kind}");
562            record_parse_error(Some(message.clone()));
563            tracing::warn!("{message} ({})", path.display());
564            eprintln!(
565                "\x1b[33m[lean-ctx] WARNING: {message} ({})\n  \
566                 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
567                path.display()
568            );
569            Self::default()
570        } else {
571            record_parse_error(None);
572            Self::default()
573        };
574
575        if let Some(ref local) = local_content {
576            cfg.merge_local(local, trusted);
577        }
578
579        // A snapshot of defaults standing in for an unusable file must not be
580        // migrated back to disk.
581        if failure.is_none() {
582            cfg.migrate_v4_config_on_disk();
583        }
584        // Last, so no merge step can drop it: the marker belongs to this value.
585        cfg.load_failure = failure;
586
587        let cfg = Arc::new(cfg);
588        if let Ok(mut guard) = cache.lock() {
589            *guard = Some((
590                Arc::clone(&cfg),
591                global_hash,
592                local_key,
593                selected_profile,
594                trusted,
595            ));
596        }
597
598        note_gateway_store_ceiling(&cfg);
599        cfg
600    }
601
602    /// Global config merged with the `.lean-ctx.toml` of `project_root` — not
603    /// of the process's own project. A daemon serving several repositories
604    /// must evaluate per-project settings (and their workspace trust) against
605    /// the project being processed. Uncached; same merge + trust rules as
606    /// [`load_arc`](Self::load_arc).
607    pub fn load_for_project_root(project_root: &str) -> Self {
608        let selected_profile = environment_config_profile();
609        let mut cfg = Self::path()
610            .and_then(|p| std::fs::read_to_string(p).ok())
611            .and_then(|c| parse_config_with_profile(&c, selected_profile.as_deref()).ok())
612            .unwrap_or_default();
613        let local_path = Self::local_path(project_root);
614        if crate::core::pathutil::may_probe_path(local_path.as_path())
615            && let Ok(local) = std::fs::read_to_string(&local_path)
616        {
617            let trusted = crate::core::workspace_trust::is_trusted_for(
618                std::path::Path::new(project_root),
619                &crate::core::hasher::hash_str(&local),
620            );
621            cfg.merge_local(&local, trusted);
622        }
623        cfg
624    }
625
626    // `merge_local` is in `merge.rs` (extracted for #660 LOC gate).
627
628    /// Consolidate legacy changes into one recoverable config transaction.
629    /// Preserve explicit telemetry opt-outs and the integrated preference model.
630    pub(crate) fn migrate_v4_config_on_disk(&mut self) {
631        // Telemetry was opt-in before v2, so a persisted `enabled = false`
632        // without an explicit preference is not a choice against v2. Re-enable
633        // it once, in a writable config only: a read-only (declaratively
634        // managed) file keeps exactly what its owner wrote.
635        let path = Self::path();
636        let writable = path
637            .as_deref()
638            .is_some_and(allows_automatic_config_migration);
639        let raw = path
640            .as_deref()
641            .filter(|_| writable)
642            .and_then(|path| std::fs::read_to_string(path).ok());
643        let reset_pending = crate::core::telemetry_consent::legacy_opt_out_reset_pending();
644        let reset = raw
645            .as_deref()
646            .filter(|_| reset_pending)
647            .and_then(reset_legacy_telemetry_opt_out);
648        let reenables =
649            reset.is_some() && self.telemetry.preference == super::TelemetryPreference::DefaultOn;
650        // The opt-out the migrated document will carry: with the reset applied
651        // only an explicit preference keeps telemetry off.
652        let preserve_opt_out = if reenables {
653            self.telemetry.preference == super::TelemetryPreference::ExplicitlyDisabled
654        } else {
655            self.telemetry.explicitly_disabled()
656        };
657        // The reset flips consent, so it takes effect in memory only once it is
658        // on disk. While it cannot be persisted (#2175: a refused journal), the
659        // user's saved `enabled = false` stays authoritative and the reset
660        // stays pending.
661        let persisted = self.persist_v4_migration(
662            path.as_deref(),
663            writable,
664            raw.as_deref(),
665            reset.as_deref(),
666            reset_pending,
667            preserve_opt_out,
668        );
669        if reenables && persisted {
670            self.telemetry.enabled = true;
671        }
672        let preserve_opt_out = self.telemetry.explicitly_disabled();
673        if self.cloud.contribute_enabled {
674            self.cloud.contribute_enabled = false;
675            if !preserve_opt_out {
676                self.telemetry.enabled = true;
677                self.telemetry.preference = super::TelemetryPreference::ExplicitlyEnabled;
678            }
679        }
680    }
681
682    /// Writes the migrated document through the config migration journal.
683    /// Returns whether the on-disk config now carries the migration (also
684    /// `true` when nothing had to change).
685    fn persist_v4_migration(
686        &self,
687        path: Option<&Path>,
688        writable: bool,
689        raw: Option<&str>,
690        reset: Option<&str>,
691        reset_pending: bool,
692        preserve_opt_out: bool,
693    ) -> bool {
694        let Some(path) = path else {
695            return false;
696        };
697        let Some(raw) = raw else {
698            if !writable {
699                tracing::debug!(
700                    "config migration remains in memory: source or directory is read-only"
701                );
702            }
703            // No config yet: there is no legacy opt-out to reset, now or later.
704            if reset_pending && !path.exists() {
705                crate::core::telemetry_consent::mark_legacy_opt_out_reset();
706            }
707            return false;
708        };
709        let source = reset.unwrap_or(raw);
710        let migrated = match Self::migrate_v4_config_document(
711            source,
712            environment_config_profile().as_deref(),
713            preserve_opt_out,
714        ) {
715            Ok(Some(updated)) => Some(updated),
716            Ok(None) => (source != raw).then(|| source.to_string()),
717            Err(error) => {
718                tracing::warn!("config migration failed: {error}");
719                return false;
720            }
721        };
722        let persisted = match migrated {
723            Some(updated) => {
724                match crate::config_io::write_atomic_config_migration_checked(
725                    path,
726                    &updated,
727                    Some(raw.as_bytes()),
728                ) {
729                    Ok(()) => true,
730                    Err(error) => {
731                        warn_config_migration_paused_once(path, raw, &updated, &error);
732                        false
733                    }
734                }
735            }
736            None => true,
737        };
738        if persisted {
739            clear_config_migration_pause_marker(path);
740        }
741        if persisted && reset_pending {
742            crate::core::telemetry_consent::mark_legacy_opt_out_reset();
743        }
744        persisted
745    }
746
747    pub(super) fn migrate_v4_config_document(
748        raw: &str,
749        selected_profile: Option<&str>,
750        preserve_opt_out: bool,
751    ) -> Result<Option<String>, String> {
752        let document = raw
753            .parse::<toml_edit::DocumentMut>()
754            .map_err(|error| error.to_string())?;
755        let legacy_telemetry = document
756            .get("cloud")
757            .and_then(|table| table.get("contribute_enabled"))
758            .and_then(toml_edit::Item::as_bool)
759            == Some(true);
760        let persisted_opt_out = document
761            .get("telemetry")
762            .and_then(|table| table.get("enabled"))
763            .and_then(toml_edit::Item::as_bool)
764            == Some(false);
765        let mut updated = if legacy_telemetry {
766            migrate_legacy_contribute_document(raw, preserve_opt_out || persisted_opt_out)
767                .ok_or_else(|| "invalid legacy telemetry configuration".to_string())?
768        } else {
769            raw.to_string()
770        };
771        if let Some(next) = Self::validated_v3_compression_document(&updated, selected_profile)? {
772            updated = next;
773        }
774        if let Some(next) = super::retired::migrate_retired_document(&updated)? {
775            updated = next;
776        }
777        parse_config_with_profile(&updated, selected_profile)?;
778        Ok((updated != raw).then_some(updated))
779    }
780
781    pub(crate) fn validated_v3_compression_document(
782        raw: &str,
783        selected_profile: Option<&str>,
784    ) -> Result<Option<String>, String> {
785        let migrated = Self::migrate_v3_compression_document(raw)?;
786        if let Some(ref updated) = migrated {
787            parse_config_with_profile(updated, selected_profile)?;
788        }
789        Ok(migrated)
790    }
791
792    pub(crate) fn migrate_v3_compression_document(raw: &str) -> Result<Option<String>, String> {
793        fn migrate_table(table: &mut toml_edit::Table) -> Result<bool, String> {
794            let has_legacy = ["terse_agent", "output_density", "ultra_compact"]
795                .iter()
796                .any(|key| table.get(key).is_some());
797            if !has_legacy {
798                return Ok(false);
799            }
800
801            let terse = match table.get("terse_agent") {
802                Some(item) => Some(
803                    item.as_str()
804                        .ok_or_else(|| "terse_agent must be a string".to_string())?,
805                ),
806                None => None,
807            };
808            if terse.is_some_and(|value| !matches!(value, "off" | "lite" | "full" | "ultra")) {
809                return Err("invalid terse_agent".to_string());
810            }
811            let density = match table.get("output_density") {
812                Some(item) => Some(
813                    item.as_str()
814                        .ok_or_else(|| "output_density must be a string".to_string())?,
815                ),
816                None => None,
817            };
818            if density.is_some_and(|value| !matches!(value, "normal" | "terse" | "ultra")) {
819                return Err("invalid output_density".to_string());
820            }
821            let ultra = match table.get("ultra_compact") {
822                Some(item) => Some(
823                    item.as_bool()
824                        .ok_or_else(|| "ultra_compact must be a boolean".to_string())?,
825                ),
826                None => None,
827            };
828
829            let level = if let Some(item) = table.get("compression_level") {
830                let value = item
831                    .as_str()
832                    .ok_or_else(|| "compression_level must be a string".to_string())?;
833                if !matches!(value, "off" | "lite" | "standard" | "max" | "raw") {
834                    return Err(format!("invalid compression_level: {value}"));
835                }
836                value
837            } else if ultra == Some(true) || terse == Some("ultra") || density == Some("ultra") {
838                "max"
839            } else if terse == Some("full") {
840                "standard"
841            } else if terse == Some("lite") || density == Some("terse") {
842                "lite"
843            } else {
844                "off"
845            };
846
847            table["compression_level"] = toml_edit::value(level);
848            for key in ["terse_agent", "output_density", "ultra_compact"] {
849                table.remove(key);
850            }
851            Ok(true)
852        }
853
854        let mut document = raw
855            .parse::<toml_edit::DocumentMut>()
856            .map_err(|error| error.to_string())?;
857        let mut changed = migrate_table(document.as_table_mut())?;
858        if let Some(profiles) = document
859            .get_mut("profiles")
860            .and_then(toml_edit::Item::as_table_mut)
861        {
862            for (_, profile) in profiles.iter_mut() {
863                if let Some(table) = profile.as_table_mut() {
864                    changed |= migrate_table(table)?;
865                }
866            }
867        }
868        Ok(changed.then(|| document.to_string()))
869    }
870
871    /// Loads ONLY the global config file — never merging project-local
872    /// `.lean-ctx.toml` overrides, and bypassing the in-memory cache. Every
873    /// PERSIST path must use this (or [`Config::update_global`]): [`Config::load`]
874    /// folds per-project overrides into the struct, and [`Config::save`] writes
875    /// the whole struct back to the GLOBAL file — so a `load → mutate → save`
876    /// round-trip silently leaks per-project values (and, historically, reset
877    /// customized keys) into the global config (#443). Reading global-only makes
878    /// the save leak-free by construction.
879    pub fn load_global() -> Self {
880        Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
881    }
882
883    /// Strict variant of [`Config::load_global`]: an unreadable or unparseable
884    /// global config is an error instead of silently becoming the defaults.
885    /// Consent-gated paths (telemetry send) must use this so a corrupt file
886    /// never turns into default-on behaviour.
887    pub fn try_load_global() -> Result<Self, super::error::LeanCtxError> {
888        let path = Self::path().ok_or_else(|| {
889            super::error::LeanCtxError::Config("cannot determine home directory".into())
890        })?;
891        Self::try_load_global_from(&path)
892    }
893
894    pub(super) fn try_load_global_from(path: &Path) -> Result<Self, super::error::LeanCtxError> {
895        match std::fs::read_to_string(path) {
896            Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).map_err(|error| {
897                super::error::LeanCtxError::Config(
898                    format!("refusing invalid global config.toml ({error})").into(),
899                )
900            }),
901            Ok(_) => Ok(Self::default()),
902            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
903            Err(error) => Err(error.into()),
904        }
905    }
906
907    /// Path-parameterized core of [`Config::load_global`] (unit-testable without
908    /// the real config dir). Missing, empty, or unparseable files yield
909    /// defaults; persisting callers that must not clobber a corrupt file use
910    /// [`Config::update_global`], which refuses instead.
911    pub(super) fn load_global_from(path: &Path) -> Self {
912        Self::try_load_global_from(path).unwrap_or_default()
913    }
914}
915
916/// Prefix of the per-config markers in the state dir that hold the
917/// fingerprint of the last reported paused migration. Every command loads the
918/// config, so without them one refused journal warns on every invocation
919/// (#2175).
920const CONFIG_MIGRATION_PAUSE_MARKER: &str = "config_migration_paused";
921
922/// Marker for one config file: keyed by the blake3 of its canonical write
923/// target, so two config paths (profiles, overrides) never share or clear
924/// each other's state. Content-addressed name, no time or counter.
925pub(super) fn config_migration_pause_marker(path: &Path) -> Option<std::path::PathBuf> {
926    let target =
927        crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
928    let key = blake3::hash(target.to_string_lossy().as_bytes()).to_hex();
929    crate::core::paths::state_dir()
930        .ok()
931        .map(|dir| dir.join(format!("{CONFIG_MIGRATION_PAUSE_MARKER}.{}", &key[..16])))
932}
933
934/// Why a config migration is paused and what the user can do about it.
935/// Every field is a deterministic function of the config path and the
936/// journal state on disk.
937#[derive(Debug, Clone, PartialEq, Eq)]
938pub struct ConfigMigrationPause {
939    /// Stable class, e.g. `write-refused` or `backup-mismatch`.
940    pub label: &'static str,
941    pub config: std::path::PathBuf,
942    pub backup: std::path::PathBuf,
943    pub receipt: std::path::PathBuf,
944    /// What is wrong, in one sentence.
945    pub reason: String,
946    /// The action that works in this state.
947    pub action: String,
948    /// Archive that keeps the config from before the first recorded migration.
949    pub original: Option<std::path::PathBuf>,
950}
951
952/// Describes the paused migration of `path` from the journal state on disk.
953/// A journal that verifies (or none) means the writer itself was refused.
954pub(super) fn describe_config_migration_pause(path: &Path) -> ConfigMigrationPause {
955    use crate::config_io::{ConfigMigrationJournalRefusal as Refusal, ConfigMigrationJournalState};
956    let config =
957        crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
958    let (backup, receipt) =
959        crate::config_io::config_migration_journal_files(path).unwrap_or_else(|| {
960            (
961                config.with_file_name("config.toml.v4-migration.bak"),
962                config.with_file_name("config.toml.v4-migration.json"),
963            )
964        });
965    let next = crate::config_io::config_migration_next_receipt_file(path)
966        .unwrap_or_else(|| config.with_file_name("config.toml.v4-migration.next.json"));
967    let dir = config
968        .parent()
969        .map_or_else(|| ".".to_string(), |dir| dir.display().to_string());
970    let (b, r, n, c) = (
971        backup.display(),
972        receipt.display(),
973        next.display(),
974        config.display(),
975    );
976    let fresh = "; the migration starts a fresh journal on the next command.";
977    let state = crate::config_io::config_migration_journal_state(path);
978    let (label, reason, action) = if let ConfigMigrationJournalState::Refused(refusal) = state {
979        let (reason, action) = match refusal {
980            Refusal::Symlink => (
981                "a migration journal file is a symbolic link, which lean-ctx does not follow."
982                    .to_string(),
983                format!(
984                    "Replace {b}, {r} and {n} with regular files or move them out of {dir}{fresh}"
985                ),
986            ),
987            Refusal::Unreadable => (
988                "a migration journal file cannot be read.".to_string(),
989                format!(
990                    "Make {b} and {r} readable for lean-ctx; the migration retries on the next command."
991                ),
992            ),
993            Refusal::ReceiptUnverifiable => (
994                format!(
995                    "the journal receipt {r} cannot be verified, so neither the migration nor \
996                         `lean-ctx migrate config --rollback` can use it."
997                ),
998                format!(
999                    "Check {b} (the config before the recorded migration) and {r}, then move \
1000                         both out of {dir}{fresh}"
1001                ),
1002            ),
1003            Refusal::BackupMismatch => (
1004                format!(
1005                    "the journal backup {b} does not match its receipt {r}, so neither the \
1006                         migration nor `lean-ctx migrate config --rollback` can use it."
1007                ),
1008                format!("Check {b} and {r}, then move both out of {dir}{fresh}"),
1009            ),
1010            Refusal::OrphanBackup => (
1011                format!(
1012                    "{b} has no receipt and matches neither the config nor an archived journal."
1013                ),
1014                format!("Check {b}, then move it out of {dir}{fresh}"),
1015            ),
1016            Refusal::OrphanReceipt => (
1017                format!("{r} has no backup and the config is not the source it records."),
1018                format!("Check {r}, then move it out of {dir}{fresh}"),
1019            ),
1020            Refusal::NextReceiptUnverifiable => (
1021                format!(
1022                    "the unfinished journal update {n} cannot be verified and the config \
1023                         matches neither recorded state."
1024                ),
1025                format!(
1026                    "Check {n}, then move it out of {dir}; the migration retries on the next \
1027                         command."
1028                ),
1029            ),
1030        };
1031        (refusal.label(), reason, action)
1032    } else {
1033        let mut action = format!(
1034            "Make {dir} and {c} writable for lean-ctx; the migration retries on the next command."
1035        );
1036        if state.rollback_available() {
1037            action.push_str(&format!(
1038                " `lean-ctx migrate config --rollback` restores the config recorded in {b}."
1039            ));
1040        }
1041        (
1042            "write-refused",
1043            "lean-ctx could not write the migrated config or its migration journal.".to_string(),
1044            action,
1045        )
1046    };
1047    ConfigMigrationPause {
1048        label,
1049        original: crate::config_io::original_config_migration_backup(path),
1050        config,
1051        backup,
1052        receipt,
1053        reason,
1054        action,
1055    }
1056}
1057
1058impl ConfigMigrationPause {
1059    /// The one-time warning text; stable for a given state.
1060    pub fn message(&self) -> String {
1061        let original = self
1062            .original
1063            .as_deref()
1064            .map(crate::config_io::original_config_sentence)
1065            .unwrap_or_default();
1066        format!(
1067            "config migration paused for {} ({}): {} {}{original} Reported once until the config \
1068             or the journal changes.",
1069            self.config.display(),
1070            self.label,
1071            self.reason,
1072            self.action
1073        )
1074    }
1075}
1076
1077/// The paused migration of `path`, if the last load had to pause it (for
1078/// `lean-ctx doctor`, which reports it on every run, independent of the
1079/// once-per-state warning).
1080pub fn config_migration_pause_status(path: &Path) -> Option<ConfigMigrationPause> {
1081    config_migration_pause_marker(path)
1082        .is_some_and(|marker| marker.is_file())
1083        .then(|| describe_config_migration_pause(path))
1084}
1085
1086/// Deterministic identity of one paused-migration state: the stable class,
1087/// the config path and bytes, the proposed output and every journal file.
1088/// Never the error text, which can carry random temporary file names.
1089pub(super) fn config_migration_pause_fingerprint(
1090    path: &Path,
1091    raw: &str,
1092    updated: &str,
1093    label: &str,
1094) -> String {
1095    let mut hasher = blake3::Hasher::new();
1096    let target =
1097        crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
1098    let target = target.to_string_lossy();
1099    for part in [
1100        label.as_bytes(),
1101        target.as_bytes(),
1102        raw.as_bytes(),
1103        updated.as_bytes(),
1104    ] {
1105        hasher.update(&(part.len() as u64).to_le_bytes());
1106        hasher.update(part);
1107    }
1108    let journal = crate::config_io::config_migration_journal_files(path)
1109        .map(|(backup, receipt)| vec![backup, receipt])
1110        .unwrap_or_default();
1111    let next = crate::config_io::config_migration_next_receipt_file(path);
1112    for file in journal.into_iter().chain(next) {
1113        let bytes = std::fs::read(file).unwrap_or_default();
1114        hasher.update(&(bytes.len() as u64).to_le_bytes());
1115        hasher.update(&bytes);
1116    }
1117    hasher.finalize().to_hex().to_string()
1118}
1119
1120/// Warns about a refused config migration once per distinct state; repeats
1121/// of the same state go to debug level only. Returns whether it warned.
1122pub(super) fn warn_config_migration_paused_once(
1123    path: &Path,
1124    raw: &str,
1125    updated: &str,
1126    error: &str,
1127) -> bool {
1128    let pause = describe_config_migration_pause(path);
1129    let fingerprint = config_migration_pause_fingerprint(path, raw, updated, pause.label);
1130    let marker = config_migration_pause_marker(path);
1131    let reported = marker
1132        .as_deref()
1133        .and_then(|marker| std::fs::read_to_string(marker).ok())
1134        .is_some_and(|seen| seen.lines().next() == Some(fingerprint.as_str()));
1135    // The raw error can name random temporary files: debug only.
1136    tracing::debug!("config migration paused ({}): {error}", pause.label);
1137    if reported {
1138        return false;
1139    }
1140    tracing::warn!("{}", pause.message());
1141    if let Some(marker) = marker {
1142        if let Some(parent) = marker.parent() {
1143            let _ = std::fs::create_dir_all(parent);
1144        }
1145        let _ = std::fs::write(marker, format!("{fingerprint}\n{}\n", pause.label));
1146    }
1147    true
1148}
1149
1150fn clear_config_migration_pause_marker(path: &Path) {
1151    if let Some(marker) = config_migration_pause_marker(path)
1152        && marker.exists()
1153    {
1154        let _ = std::fs::remove_file(marker);
1155    }
1156}
1157
1158// Respect declared read-only permissions even when a privileged process could
1159// bypass them. Other access restrictions still fail at the transactional writer.
1160fn allows_automatic_config_migration(path: &Path) -> bool {
1161    path.parent().is_some_and(|parent| {
1162        [path, parent].iter().all(|entry| {
1163            std::fs::metadata(entry).is_ok_and(|metadata| !metadata.permissions().readonly())
1164        })
1165    })
1166}
1167
1168/// Re-enables a pre-v2 `telemetry.enabled = false` that carries no explicit
1169/// preference. Returns `None` when there is nothing to reset, including every
1170/// explicit choice (`explicitly_disabled` stays off).
1171pub(super) fn reset_legacy_telemetry_opt_out(raw: &str) -> Option<String> {
1172    let mut document = raw.parse::<toml_edit::DocumentMut>().ok()?;
1173    let telemetry = document.get("telemetry")?;
1174    let disabled = telemetry.get("enabled").and_then(toml_edit::Item::as_bool) == Some(false);
1175    let implicit = matches!(
1176        telemetry.get("preference").map(toml_edit::Item::as_str),
1177        None | Some(Some("default_on"))
1178    );
1179    if !(disabled && implicit) {
1180        return None;
1181    }
1182    document["telemetry"]["enabled"] = toml_edit::value(true);
1183    Some(document.to_string())
1184}
1185
1186fn migrate_legacy_contribute_document(raw: &str, preserve_opt_out: bool) -> Option<String> {
1187    let mut document = raw.parse::<toml_edit::DocumentMut>().ok()?;
1188    document["cloud"]["contribute_enabled"] = toml_edit::value(false);
1189    if !preserve_opt_out {
1190        document["telemetry"]["enabled"] = toml_edit::value(true);
1191        document["telemetry"]["preference"] = toml_edit::value("explicitly_enabled");
1192    }
1193    Some(document.to_string())
1194}
1195
1196#[cfg(test)]
1197mod telemetry_migration_tests {
1198    use super::*;
1199
1200    #[test]
1201    #[cfg(unix)]
1202    fn automatic_migration_respects_readonly_file_and_directory() {
1203        use std::os::unix::fs::PermissionsExt;
1204        let directory = tempfile::tempdir().expect("migration directory");
1205        let path = directory.path().join("config.toml");
1206        std::fs::write(&path, "ultra_compact = true\n").expect("legacy config");
1207        assert!(allows_automatic_config_migration(&path));
1208        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
1209        let readonly_file = allows_automatic_config_migration(&path);
1210        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1211        std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
1212        let readonly_directory = allows_automatic_config_migration(&path);
1213        std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1214        assert!(!readonly_file);
1215        assert!(!readonly_directory);
1216        assert!(!allows_automatic_config_migration(
1217            &directory.path().join("missing.toml")
1218        ));
1219    }
1220
1221    #[test]
1222    fn legacy_opt_in_becomes_explicit_and_send_eligible() {
1223        let migrated = migrate_legacy_contribute_document(
1224            "# keep me\n[cloud]\ncontribute_enabled = true\n",
1225            false,
1226        )
1227        .expect("valid TOML");
1228        assert!(migrated.contains("# keep me"));
1229        let cfg: Config = toml::from_str(&migrated).expect("migrated config");
1230        assert!(!cfg.cloud.contribute_enabled);
1231        assert!(cfg.telemetry.enabled);
1232        assert_eq!(
1233            cfg.telemetry.preference,
1234            super::super::TelemetryPreference::ExplicitlyEnabled
1235        );
1236        assert!(cfg.telemetry.send_eligible(None, None));
1237    }
1238
1239    #[test]
1240    fn legacy_contribute_never_overrides_explicit_opt_out() {
1241        let migrated = migrate_legacy_contribute_document(
1242            "[cloud]\ncontribute_enabled = true\n[telemetry]\nenabled = false\n",
1243            true,
1244        )
1245        .expect("valid TOML");
1246        let cfg: Config = toml::from_str(&migrated).expect("migrated config");
1247        assert!(!cfg.cloud.contribute_enabled);
1248        assert!(cfg.telemetry.explicitly_disabled());
1249    }
1250}