Skip to main content

scv_server/config/
load.rs

1//! Reading and layering configuration: defaults, the user's `config.toml`,
2//! a project's `.scv/config.toml`, the explicit `--config` file
3//! (`SCV_CONFIG`), then environment and flags.
4
5use std::{collections::BTreeMap, io::Write, path::PathBuf};
6
7use anyhow::{Context, Result, bail};
8use scv_client::Layout;
9
10use super::{
11    Config, ConfigOverrides, Deprecated, ProviderConfig, legacy,
12    validate::{validate_project_keys, validate_project_not_weaker},
13};
14
15const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
16
17impl Config {
18    /// Write a starter `config.toml` into the instance unless it has one, and
19    /// return its path.
20    pub fn init_user_config(layout: &Layout) -> Result<PathBuf> {
21        let path = layout.config();
22        if let Some(parent) = path.parent() {
23            std::fs::create_dir_all(parent).context("create config directory")?;
24            ensure_private_dir(parent)?;
25        }
26        let content = "[provider]\nactive = \"openai\"\n\n[providers.openai]\nkind = \"openai-compatible\"\nmodel = \"gpt-4.1-mini\"\nbase_url = \"https://api.openai.com/v1\"\napi_key_env = \"OPENAI_API_KEY\"\n";
27        if !path.exists() {
28            let parent = path
29                .parent()
30                .ok_or_else(|| anyhow::anyhow!("configuration path has no parent"))?;
31            let mut temporary = tempfile::NamedTempFile::new_in(parent)
32                .context("create temporary example configuration")?;
33            #[cfg(unix)]
34            {
35                use std::os::unix::fs::PermissionsExt;
36                temporary
37                    .as_file()
38                    .set_permissions(std::fs::Permissions::from_mode(0o600))
39                    .context("secure temporary configuration")?;
40            }
41            temporary
42                .write_all(content.as_bytes())
43                .context("write example configuration")?;
44            temporary
45                .as_file()
46                .sync_all()
47                .context("sync example configuration")?;
48            match temporary.persist(&path) {
49                Ok(_) => {}
50                Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {}
51                Err(error) => return Err(error.error).context("install example configuration"),
52            }
53        }
54        Ok(path)
55    }
56    pub(crate) fn active_provider(&self) -> Result<ProviderConfig> {
57        if let Some(name) = self
58            .provider_active
59            .as_deref()
60            .or(self.provider.active.as_deref())
61        {
62            let profile =
63                self.providers.get(name).cloned().ok_or_else(|| {
64                    anyhow::anyhow!("active provider profile {name:?} was not found")
65                })?;
66            // The profile replaces `[provider]` whole, so an effort set there
67            // would be dropped without a word.
68            if self.provider.reasoning_effort.is_some() {
69                bail!(
70                    "provider.reasoning_effort has no effect while profile {name:?} is \
71                     active; set it in [providers.{name}]"
72                );
73            }
74            return Ok(profile);
75        }
76        Ok(self.provider.clone())
77    }
78}
79
80impl Config {
81    /// The configuration a session started in `workspace` of the instance at
82    /// `layout` runs with.
83    pub fn load(
84        layout: &Layout,
85        workspace: &std::path::Path,
86        overrides: ConfigOverrides,
87    ) -> Result<Self> {
88        Self::load_layers(layout, Some(workspace), overrides)
89    }
90
91    /// Load without a project layer, for settings that project configuration
92    /// can never set (such as `[agents]`), so the caller's directory is irrelevant.
93    pub fn load_user(layout: &Layout, overrides: ConfigOverrides) -> Result<Self> {
94        Self::load_layers(layout, None, overrides)
95    }
96
97    fn load_layers(
98        layout: &Layout,
99        workspace: Option<&std::path::Path>,
100        overrides: ConfigOverrides,
101    ) -> Result<Self> {
102        let instance_home = layout.home().to_owned();
103        std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
104        ensure_private_dir(&instance_home)?;
105        let mut value: toml::Value = toml::from_str(
106            &toml::to_string(&Self::default()).context("serialize default configuration")?,
107        )?;
108
109        let mut deprecated = Vec::new();
110        let user_path = layout.config();
111        if user_path.is_file() {
112            #[cfg(unix)]
113            {
114                use std::os::unix::fs::PermissionsExt;
115                if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
116                    bail!("user configuration is readable by group or others; run chmod 600");
117                }
118            }
119            merge(
120                &mut value,
121                read_migrated(&user_path, USER_LAYER, &mut deprecated)?,
122            );
123        }
124        let user_baseline: Self = value
125            .clone()
126            .try_into()
127            .context("parse user configuration")?;
128
129        if let Some(workspace) = workspace {
130            let project_path = workspace.join(".scv/config.toml");
131            // A workspace whose `.scv` is the SCV home (such as running from
132            // `~`) has no project layer: that file is the user configuration,
133            // already applied above at full trust.
134            let user_file = std::fs::canonicalize(layout.config()).ok();
135            if project_path.is_file() {
136                let canonical_project = std::fs::canonicalize(&project_path)
137                    .with_context(|| format!("resolve configuration {}", project_path.display()))?;
138                if user_file.as_ref() != Some(&canonical_project) {
139                    if !canonical_project.starts_with(workspace) {
140                        bail!("project configuration escaped workspace");
141                    }
142                    let project =
143                        read_migrated(&canonical_project, PROJECT_LAYER, &mut deprecated)?;
144                    validate_project_keys(&project)?;
145                    let mut candidate_value = value.clone();
146                    merge(&mut candidate_value, project);
147                    let candidate: Self = candidate_value
148                        .clone()
149                        .try_into()
150                        .context("parse project configuration")?;
151                    validate_project_not_weaker(&user_baseline, &candidate)?;
152                    value = candidate_value;
153                }
154            }
155        }
156
157        if let Some(path) = &overrides.config_file {
158            #[cfg(unix)]
159            {
160                use std::os::unix::fs::PermissionsExt;
161                if std::fs::metadata(path)?.permissions().mode() & 0o077 != 0 {
162                    bail!("explicit configuration is readable by group or others; run chmod 600");
163                }
164            }
165            let explicit = read_migrated(path, EXPLICIT_LAYER, &mut deprecated)?;
166            if explicit.get("channels").is_some() {
167                bail!(
168                    "{} cannot set [channels]; channel accounts belong in the instance's config.toml",
169                    path.display()
170                );
171            }
172            merge(&mut value, explicit);
173        }
174        let mut config: Self = value.try_into().context("parse merged configuration")?;
175        if let Some(name) = overrides.provider.as_deref() {
176            config.provider_active = Some(name.to_owned());
177        }
178        let selected = config.active_provider()?;
179        config.provider = selected;
180        if let Ok(model) = std::env::var("SCV_MODEL") {
181            config.provider.model = model;
182        }
183        if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
184            config.provider.base_url = base_url;
185        }
186        if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
187            config.provider.api_key_env = Some(api_key_env);
188        }
189        if let Some(model) = overrides.model {
190            config.provider.model = model;
191        }
192        if let Some(base_url) = overrides.base_url {
193            config.provider.base_url = base_url;
194        }
195        if let Some(mode) = overrides.mode {
196            config.policy.mode = mode;
197        }
198        if config.skills.user_dir == std::path::Path::new("~/.scv/skills") {
199            config.skills.user_dir = layout.skills();
200        }
201        config.skills.user_dir = expand_home(&config.skills.user_dir);
202        if let Some(archive) = &config.history.archive_dir {
203            config.history.archive_dir = Some(expand_home(archive));
204        }
205        config.instance_home = instance_home;
206        config.deprecated = deprecated;
207        config.validate()?;
208        Ok(config)
209    }
210
211    /// Every leaf setting after layering, as a session started in
212    /// `workspace` would see it, with the layer that set it. Secret values are
213    /// replaced by `<hidden>`. Channel accounts are left out: `scv config
214    /// show` reports them with their credentials.
215    pub fn settings_with_origins(
216        layout: &Layout,
217        workspace: Option<&std::path::Path>,
218        overrides: &ConfigOverrides,
219    ) -> Result<Vec<Setting>> {
220        let mut settings: BTreeMap<String, (toml::Value, String)> = BTreeMap::new();
221        let mut apply = |value: &toml::Value, origin: &str| {
222            flatten(value, String::new(), &mut |key, value| {
223                settings.insert(key, (value.clone(), origin.to_owned()));
224            });
225        };
226        let mut deprecated = Vec::new();
227        let defaults: toml::Value = toml::from_str(
228            &toml::to_string(&Self::default()).context("serialize default configuration")?,
229        )?;
230        apply(&defaults, "default");
231        let mut merged = defaults;
232        let user = Some(layout.config()).filter(|path| path.is_file());
233        if let Some(path) = &user {
234            let layer = read_migrated(path, USER_LAYER, &mut deprecated)?;
235            apply(&layer, USER_LAYER);
236            merge(&mut merged, layer);
237        }
238        if let Some(workspace) = workspace {
239            let project = workspace.join(".scv/config.toml");
240            let user_file = user
241                .as_ref()
242                .and_then(|path| std::fs::canonicalize(path).ok());
243            if project.is_file() && std::fs::canonicalize(&project).ok() != user_file {
244                let layer = read_migrated(&project, PROJECT_LAYER, &mut deprecated)?;
245                apply(&layer, PROJECT_LAYER);
246                merge(&mut merged, layer);
247            }
248        }
249        if let Some(path) = &overrides.config_file {
250            let layer = read_migrated(path, EXPLICIT_LAYER, &mut deprecated)?;
251            apply(&layer, EXPLICIT_LAYER);
252            merge(&mut merged, layer);
253        }
254        // Environment and flags change the provider in effect: a named
255        // profile's fields when profiles are used, or `[provider]` itself.
256        let active = overrides.provider.clone().or_else(|| {
257            merged
258                .get("provider")?
259                .get("active")?
260                .as_str()
261                .map(ToOwned::to_owned)
262        });
263        let has_profiles = merged
264            .get("providers")
265            .and_then(toml::Value::as_table)
266            .is_some_and(|profiles| !profiles.is_empty());
267        let prefix = match active {
268            Some(name) if has_profiles => format!("providers.{name}"),
269            _ => "provider".into(),
270        };
271        let mut set = |key: String, value: String, origin: &str| {
272            settings.insert(key, (toml::Value::String(value), origin.to_owned()));
273        };
274        if let Some(name) = &overrides.provider {
275            set("provider.active".into(), name.clone(), "--provider flag");
276        }
277        for (field, variable) in [
278            ("model", "SCV_MODEL"),
279            ("base_url", "SCV_BASE_URL"),
280            ("api_key_env", "SCV_API_KEY_ENV"),
281        ] {
282            if let Ok(value) = std::env::var(variable) {
283                set(
284                    format!("{prefix}.{field}"),
285                    value,
286                    &format!("env {variable}"),
287                );
288            }
289        }
290        for (field, value, flag) in [
291            ("model", &overrides.model, "--model flag"),
292            ("base_url", &overrides.base_url, "--base-url flag"),
293        ] {
294            if let Some(value) = value {
295                set(format!("{prefix}.{field}"), value.clone(), flag);
296            }
297        }
298        if let Some(mode) = overrides.mode {
299            settings.insert(
300                "policy.mode".into(),
301                (mode.as_str().into(), "--policy flag".into()),
302            );
303        }
304        Ok(settings
305            .into_iter()
306            .filter(|(key, _)| !key.starts_with("channels."))
307            .map(|(key, (value, origin))| Setting {
308                value: if is_secret_key(&key) {
309                    "<hidden>".into()
310                } else {
311                    value.to_string()
312                },
313                // The line in effect for this key, when it was a deprecated one.
314                note: deprecated
315                    .iter()
316                    .rev()
317                    .find(|old: &&Deprecated| old.replacement == key && old.origin == origin)
318                    .map(Deprecated::note),
319                key,
320                origin,
321            })
322            .collect())
323    }
324}
325
326/// How `scv config show` and the deprecation warnings name each layer.
327const USER_LAYER: &str = "config.toml";
328const PROJECT_LAYER: &str = "project .scv/config.toml";
329const EXPLICIT_LAYER: &str = "SCV_CONFIG";
330
331/// [`read_layer`], with its deprecated keys read as their replacements and
332/// added to `deprecated`.
333fn read_migrated(
334    path: &std::path::Path,
335    origin: &str,
336    deprecated: &mut Vec<Deprecated>,
337) -> Result<toml::Value> {
338    let mut layer = read_layer(path)?;
339    deprecated.extend(legacy::migrate(&mut layer, origin)?);
340    Ok(layer)
341}
342
343pub(super) fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
344    #[cfg(unix)]
345    {
346        use std::os::unix::fs::PermissionsExt;
347        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
348            .with_context(|| format!("secure directory {}", path.display()))?;
349    }
350    Ok(())
351}
352
353/// Read one configuration file as TOML, refusing files over 1 MiB. Parse
354/// errors name the line but never quote it, since it may hold a key.
355pub fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
356    let size = std::fs::metadata(path)
357        .with_context(|| format!("stat configuration {}", path.display()))?
358        .len();
359    if size > MAX_CONFIG_BYTES {
360        bail!("configuration {} exceeds 1 MiB", path.display());
361    }
362    let content = std::fs::read_to_string(path)
363        .with_context(|| format!("read configuration {}", path.display()))?;
364    // The parser's own display quotes the offending line, which may hold a
365    // key, so only its message and line number are kept.
366    toml::from_str(&content).map_err(|error: toml::de::Error| {
367        let line = error.span().map_or_else(String::new, |span| {
368            format!(
369                " line {}",
370                content[..span.start.min(content.len())]
371                    .matches('\n')
372                    .count()
373                    + 1
374            )
375        });
376        anyhow::anyhow!(
377            "parse configuration {}{line}: {}",
378            path.display(),
379            error.message()
380        )
381    })
382}
383
384pub(super) fn merge(base: &mut toml::Value, overlay: toml::Value) {
385    match (base, overlay) {
386        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
387            for (key, value) in overlay {
388                match base.get_mut(&key) {
389                    Some(existing) => merge(existing, value),
390                    None => {
391                        base.insert(key, value);
392                    }
393                }
394            }
395        }
396        (base, overlay) => *base = overlay,
397    }
398}
399
400/// A configuration value in effect and the layer that set it.
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct Setting {
403    /// Dotted key, such as `policy.mode`.
404    pub key: String,
405    /// The value as TOML, or `<hidden>` for a secret.
406    pub value: String,
407    /// `default`, `config.toml`, `project .scv/config.toml`, `SCV_CONFIG`,
408    /// `env <VARIABLE>`, or `--<name> flag`.
409    pub origin: String,
410    /// Set by a deprecated key read as this one: which, and whether it pins
411    /// the old default.
412    pub note: Option<String>,
413}
414
415/// Call `visit` with every leaf of `value` under its dotted key.
416fn flatten(value: &toml::Value, prefix: String, visit: &mut impl FnMut(String, &toml::Value)) {
417    match value {
418        toml::Value::Table(table) => {
419            for (key, value) in table {
420                let key = if prefix.is_empty() {
421                    key.clone()
422                } else {
423                    format!("{prefix}.{key}")
424                };
425                flatten(value, key, visit);
426            }
427        }
428        leaf => visit(prefix, leaf),
429    }
430}
431
432/// Keys whose values are credentials: API keys, secrets, passwords, and
433/// provider headers, which commonly carry authorization.
434pub(super) fn is_secret_key(key: &str) -> bool {
435    let last = key.rsplit('.').next().unwrap_or(key);
436    last == "api_key"
437        || last.ends_with("_api_key")
438        || last.contains("secret")
439        || last.contains("password")
440        || key.split('.').any(|segment| segment == "headers")
441}
442
443fn expand_home(path: &std::path::Path) -> PathBuf {
444    let value = path.to_string_lossy();
445    if value == "~" {
446        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
447    }
448    if let Some(rest) = value.strip_prefix("~/")
449        && let Some(home) = dirs::home_dir()
450    {
451        return home.join(rest);
452    }
453    path.to_path_buf()
454}