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, ProviderConfig,
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            return self
63                .providers
64                .get(name)
65                .cloned()
66                .ok_or_else(|| anyhow::anyhow!("active provider profile {name:?} was not found"));
67        }
68        Ok(self.provider.clone())
69    }
70}
71
72impl Config {
73    /// The configuration a session started in `workspace` of the instance at
74    /// `layout` runs with.
75    pub fn load(
76        layout: &Layout,
77        workspace: &std::path::Path,
78        overrides: ConfigOverrides,
79    ) -> Result<Self> {
80        Self::load_layers(layout, Some(workspace), overrides)
81    }
82
83    /// Load without a project layer, for settings that project configuration
84    /// can never set (such as `[agents]`), so the caller's directory is irrelevant.
85    pub fn load_user(layout: &Layout, overrides: ConfigOverrides) -> Result<Self> {
86        Self::load_layers(layout, None, overrides)
87    }
88
89    fn load_layers(
90        layout: &Layout,
91        workspace: Option<&std::path::Path>,
92        overrides: ConfigOverrides,
93    ) -> Result<Self> {
94        let instance_home = layout.home().to_owned();
95        std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
96        ensure_private_dir(&instance_home)?;
97        let mut value: toml::Value = toml::from_str(
98            &toml::to_string(&Self::default()).context("serialize default configuration")?,
99        )?;
100
101        let user_path = layout.config();
102        if user_path.is_file() {
103            #[cfg(unix)]
104            {
105                use std::os::unix::fs::PermissionsExt;
106                if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
107                    bail!("user configuration is readable by group or others; run chmod 600");
108                }
109            }
110            merge(&mut value, read_layer(&user_path)?);
111        }
112        let user_baseline: Self = value
113            .clone()
114            .try_into()
115            .context("parse user configuration")?;
116
117        if let Some(workspace) = workspace {
118            let project_path = workspace.join(".scv/config.toml");
119            // A workspace whose `.scv` is the SCV home (such as running from
120            // `~`) has no project layer: that file is the user configuration,
121            // already applied above at full trust.
122            let user_file = std::fs::canonicalize(layout.config()).ok();
123            if project_path.is_file() {
124                let canonical_project = std::fs::canonicalize(&project_path)
125                    .with_context(|| format!("resolve configuration {}", project_path.display()))?;
126                if user_file.as_ref() != Some(&canonical_project) {
127                    if !canonical_project.starts_with(workspace) {
128                        bail!("project configuration escaped workspace");
129                    }
130                    let project = read_layer(&canonical_project)?;
131                    validate_project_keys(&project)?;
132                    let mut candidate_value = value.clone();
133                    merge(&mut candidate_value, project);
134                    let candidate: Self = candidate_value
135                        .clone()
136                        .try_into()
137                        .context("parse project configuration")?;
138                    validate_project_not_weaker(&user_baseline, &candidate)?;
139                    value = candidate_value;
140                }
141            }
142        }
143
144        if let Some(path) = &overrides.config_file {
145            #[cfg(unix)]
146            {
147                use std::os::unix::fs::PermissionsExt;
148                if std::fs::metadata(path)?.permissions().mode() & 0o077 != 0 {
149                    bail!("explicit configuration is readable by group or others; run chmod 600");
150                }
151            }
152            let explicit = read_layer(path)?;
153            if explicit.get("channels").is_some() {
154                bail!(
155                    "{} cannot set [channels]; channel accounts belong in the instance's config.toml",
156                    path.display()
157                );
158            }
159            merge(&mut value, explicit);
160        }
161        let mut config: Self = value.try_into().context("parse merged configuration")?;
162        if let Some(name) = overrides.provider.as_deref() {
163            config.provider_active = Some(name.to_owned());
164        }
165        let selected = config.active_provider()?;
166        config.provider = selected;
167        if let Ok(model) = std::env::var("SCV_MODEL") {
168            config.provider.model = model;
169        }
170        if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
171            config.provider.base_url = base_url;
172        }
173        if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
174            config.provider.api_key_env = Some(api_key_env);
175        }
176        if let Some(model) = overrides.model {
177            config.provider.model = model;
178        }
179        if let Some(base_url) = overrides.base_url {
180            config.provider.base_url = base_url;
181        }
182        if let Some(policy) = overrides.approval_policy {
183            config.tools.approval_policy = policy;
184        }
185        if config.skills.user_dir == std::path::Path::new("~/.scv/skills") {
186            config.skills.user_dir = layout.skills();
187        }
188        config.skills.user_dir = expand_home(&config.skills.user_dir);
189        config.instance_home = instance_home;
190        config.validate()?;
191        Ok(config)
192    }
193
194    /// Every leaf setting after layering, as a session started in
195    /// `workspace` would see it, with the layer that set it. Secret values are
196    /// replaced by `<hidden>`. Channel accounts are left out: `scv config
197    /// show` reports them with their credentials.
198    pub fn settings_with_origins(
199        layout: &Layout,
200        workspace: Option<&std::path::Path>,
201        overrides: &ConfigOverrides,
202    ) -> Result<Vec<Setting>> {
203        let mut settings: BTreeMap<String, (toml::Value, String)> = BTreeMap::new();
204        let mut apply = |value: &toml::Value, origin: &str| {
205            flatten(value, String::new(), &mut |key, value| {
206                settings.insert(key, (value.clone(), origin.to_owned()));
207            });
208        };
209        let defaults: toml::Value = toml::from_str(
210            &toml::to_string(&Self::default()).context("serialize default configuration")?,
211        )?;
212        apply(&defaults, "default");
213        let mut merged = defaults;
214        let user = Some(layout.config()).filter(|path| path.is_file());
215        if let Some(path) = &user {
216            let layer = read_layer(path)?;
217            apply(&layer, "config.toml");
218            merge(&mut merged, layer);
219        }
220        if let Some(workspace) = workspace {
221            let project = workspace.join(".scv/config.toml");
222            let user_file = user
223                .as_ref()
224                .and_then(|path| std::fs::canonicalize(path).ok());
225            if project.is_file() && std::fs::canonicalize(&project).ok() != user_file {
226                let layer = read_layer(&project)?;
227                apply(&layer, "project .scv/config.toml");
228                merge(&mut merged, layer);
229            }
230        }
231        if let Some(path) = &overrides.config_file {
232            let layer = read_layer(path)?;
233            apply(&layer, "SCV_CONFIG");
234            merge(&mut merged, layer);
235        }
236        // Environment and flags change the provider in effect: a named
237        // profile's fields when profiles are used, or `[provider]` itself.
238        let active = overrides.provider.clone().or_else(|| {
239            merged
240                .get("provider")?
241                .get("active")?
242                .as_str()
243                .map(ToOwned::to_owned)
244        });
245        let has_profiles = merged
246            .get("providers")
247            .and_then(toml::Value::as_table)
248            .is_some_and(|profiles| !profiles.is_empty());
249        let prefix = match active {
250            Some(name) if has_profiles => format!("providers.{name}"),
251            _ => "provider".into(),
252        };
253        let mut set = |key: String, value: String, origin: &str| {
254            settings.insert(key, (toml::Value::String(value), origin.to_owned()));
255        };
256        if let Some(name) = &overrides.provider {
257            set("provider.active".into(), name.clone(), "--provider flag");
258        }
259        for (field, variable) in [
260            ("model", "SCV_MODEL"),
261            ("base_url", "SCV_BASE_URL"),
262            ("api_key_env", "SCV_API_KEY_ENV"),
263        ] {
264            if let Ok(value) = std::env::var(variable) {
265                set(
266                    format!("{prefix}.{field}"),
267                    value,
268                    &format!("env {variable}"),
269                );
270            }
271        }
272        for (field, value, flag) in [
273            ("model", &overrides.model, "--model flag"),
274            ("base_url", &overrides.base_url, "--base-url flag"),
275        ] {
276            if let Some(value) = value {
277                set(format!("{prefix}.{field}"), value.clone(), flag);
278            }
279        }
280        if let Some(policy) = overrides.approval_policy {
281            let value = toml::Value::try_from(policy).context("serialize approval policy")?;
282            settings.insert(
283                "tools.approval_policy".into(),
284                (value, "--approval-policy flag".into()),
285            );
286        }
287        Ok(settings
288            .into_iter()
289            .filter(|(key, _)| !key.starts_with("channels."))
290            .map(|(key, (value, origin))| Setting {
291                value: if is_secret_key(&key) {
292                    "<hidden>".into()
293                } else {
294                    value.to_string()
295                },
296                key,
297                origin,
298            })
299            .collect())
300    }
301}
302
303pub(super) fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
304    #[cfg(unix)]
305    {
306        use std::os::unix::fs::PermissionsExt;
307        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
308            .with_context(|| format!("secure directory {}", path.display()))?;
309    }
310    Ok(())
311}
312
313/// Read one configuration file as TOML, refusing files over 1 MiB. Parse
314/// errors name the line but never quote it, since it may hold a key.
315pub fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
316    let size = std::fs::metadata(path)
317        .with_context(|| format!("stat configuration {}", path.display()))?
318        .len();
319    if size > MAX_CONFIG_BYTES {
320        bail!("configuration {} exceeds 1 MiB", path.display());
321    }
322    let content = std::fs::read_to_string(path)
323        .with_context(|| format!("read configuration {}", path.display()))?;
324    // The parser's own display quotes the offending line, which may hold a
325    // key, so only its message and line number are kept.
326    toml::from_str(&content).map_err(|error: toml::de::Error| {
327        let line = error.span().map_or_else(String::new, |span| {
328            format!(
329                " line {}",
330                content[..span.start.min(content.len())]
331                    .matches('\n')
332                    .count()
333                    + 1
334            )
335        });
336        anyhow::anyhow!(
337            "parse configuration {}{line}: {}",
338            path.display(),
339            error.message()
340        )
341    })
342}
343
344pub(super) fn merge(base: &mut toml::Value, overlay: toml::Value) {
345    match (base, overlay) {
346        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
347            for (key, value) in overlay {
348                match base.get_mut(&key) {
349                    Some(existing) => merge(existing, value),
350                    None => {
351                        base.insert(key, value);
352                    }
353                }
354            }
355        }
356        (base, overlay) => *base = overlay,
357    }
358}
359
360/// A configuration value in effect and the layer that set it.
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct Setting {
363    /// Dotted key, such as `tools.approval_policy`.
364    pub key: String,
365    /// The value as TOML, or `<hidden>` for a secret.
366    pub value: String,
367    /// `default`, `config.toml`, `project .scv/config.toml`, `SCV_CONFIG`,
368    /// `env <VARIABLE>`, or `--<name> flag`.
369    pub origin: String,
370}
371
372/// Call `visit` with every leaf of `value` under its dotted key.
373fn flatten(value: &toml::Value, prefix: String, visit: &mut impl FnMut(String, &toml::Value)) {
374    match value {
375        toml::Value::Table(table) => {
376            for (key, value) in table {
377                let key = if prefix.is_empty() {
378                    key.clone()
379                } else {
380                    format!("{prefix}.{key}")
381                };
382                flatten(value, key, visit);
383            }
384        }
385        leaf => visit(prefix, leaf),
386    }
387}
388
389/// Keys whose values are credentials: API keys, secrets, passwords, and
390/// provider headers, which commonly carry authorization.
391pub(super) fn is_secret_key(key: &str) -> bool {
392    let last = key.rsplit('.').next().unwrap_or(key);
393    last == "api_key"
394        || last.ends_with("_api_key")
395        || last.contains("secret")
396        || last.contains("password")
397        || key.split('.').any(|segment| segment == "headers")
398}
399
400fn expand_home(path: &std::path::Path) -> PathBuf {
401    let value = path.to_string_lossy();
402    if value == "~" {
403        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
404    }
405    if let Some(rest) = value.strip_prefix("~/")
406        && let Some(home) = dirs::home_dir()
407    {
408        return home.join(rest);
409    }
410    path.to_path_buf()
411}