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        if let Some(archive) = &config.history.archive_dir {
190            config.history.archive_dir = Some(expand_home(archive));
191        }
192        config.instance_home = instance_home;
193        config.validate()?;
194        Ok(config)
195    }
196
197    /// Every leaf setting after layering, as a session started in
198    /// `workspace` would see it, with the layer that set it. Secret values are
199    /// replaced by `<hidden>`. Channel accounts are left out: `scv config
200    /// show` reports them with their credentials.
201    pub fn settings_with_origins(
202        layout: &Layout,
203        workspace: Option<&std::path::Path>,
204        overrides: &ConfigOverrides,
205    ) -> Result<Vec<Setting>> {
206        let mut settings: BTreeMap<String, (toml::Value, String)> = BTreeMap::new();
207        let mut apply = |value: &toml::Value, origin: &str| {
208            flatten(value, String::new(), &mut |key, value| {
209                settings.insert(key, (value.clone(), origin.to_owned()));
210            });
211        };
212        let defaults: toml::Value = toml::from_str(
213            &toml::to_string(&Self::default()).context("serialize default configuration")?,
214        )?;
215        apply(&defaults, "default");
216        let mut merged = defaults;
217        let user = Some(layout.config()).filter(|path| path.is_file());
218        if let Some(path) = &user {
219            let layer = read_layer(path)?;
220            apply(&layer, "config.toml");
221            merge(&mut merged, layer);
222        }
223        if let Some(workspace) = workspace {
224            let project = workspace.join(".scv/config.toml");
225            let user_file = user
226                .as_ref()
227                .and_then(|path| std::fs::canonicalize(path).ok());
228            if project.is_file() && std::fs::canonicalize(&project).ok() != user_file {
229                let layer = read_layer(&project)?;
230                apply(&layer, "project .scv/config.toml");
231                merge(&mut merged, layer);
232            }
233        }
234        if let Some(path) = &overrides.config_file {
235            let layer = read_layer(path)?;
236            apply(&layer, "SCV_CONFIG");
237            merge(&mut merged, layer);
238        }
239        // Environment and flags change the provider in effect: a named
240        // profile's fields when profiles are used, or `[provider]` itself.
241        let active = overrides.provider.clone().or_else(|| {
242            merged
243                .get("provider")?
244                .get("active")?
245                .as_str()
246                .map(ToOwned::to_owned)
247        });
248        let has_profiles = merged
249            .get("providers")
250            .and_then(toml::Value::as_table)
251            .is_some_and(|profiles| !profiles.is_empty());
252        let prefix = match active {
253            Some(name) if has_profiles => format!("providers.{name}"),
254            _ => "provider".into(),
255        };
256        let mut set = |key: String, value: String, origin: &str| {
257            settings.insert(key, (toml::Value::String(value), origin.to_owned()));
258        };
259        if let Some(name) = &overrides.provider {
260            set("provider.active".into(), name.clone(), "--provider flag");
261        }
262        for (field, variable) in [
263            ("model", "SCV_MODEL"),
264            ("base_url", "SCV_BASE_URL"),
265            ("api_key_env", "SCV_API_KEY_ENV"),
266        ] {
267            if let Ok(value) = std::env::var(variable) {
268                set(
269                    format!("{prefix}.{field}"),
270                    value,
271                    &format!("env {variable}"),
272                );
273            }
274        }
275        for (field, value, flag) in [
276            ("model", &overrides.model, "--model flag"),
277            ("base_url", &overrides.base_url, "--base-url flag"),
278        ] {
279            if let Some(value) = value {
280                set(format!("{prefix}.{field}"), value.clone(), flag);
281            }
282        }
283        if let Some(policy) = overrides.approval_policy {
284            let value = toml::Value::try_from(policy).context("serialize approval policy")?;
285            settings.insert(
286                "tools.approval_policy".into(),
287                (value, "--approval-policy flag".into()),
288            );
289        }
290        Ok(settings
291            .into_iter()
292            .filter(|(key, _)| !key.starts_with("channels."))
293            .map(|(key, (value, origin))| Setting {
294                value: if is_secret_key(&key) {
295                    "<hidden>".into()
296                } else {
297                    value.to_string()
298                },
299                key,
300                origin,
301            })
302            .collect())
303    }
304}
305
306pub(super) fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
307    #[cfg(unix)]
308    {
309        use std::os::unix::fs::PermissionsExt;
310        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
311            .with_context(|| format!("secure directory {}", path.display()))?;
312    }
313    Ok(())
314}
315
316/// Read one configuration file as TOML, refusing files over 1 MiB. Parse
317/// errors name the line but never quote it, since it may hold a key.
318pub fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
319    let size = std::fs::metadata(path)
320        .with_context(|| format!("stat configuration {}", path.display()))?
321        .len();
322    if size > MAX_CONFIG_BYTES {
323        bail!("configuration {} exceeds 1 MiB", path.display());
324    }
325    let content = std::fs::read_to_string(path)
326        .with_context(|| format!("read configuration {}", path.display()))?;
327    // The parser's own display quotes the offending line, which may hold a
328    // key, so only its message and line number are kept.
329    toml::from_str(&content).map_err(|error: toml::de::Error| {
330        let line = error.span().map_or_else(String::new, |span| {
331            format!(
332                " line {}",
333                content[..span.start.min(content.len())]
334                    .matches('\n')
335                    .count()
336                    + 1
337            )
338        });
339        anyhow::anyhow!(
340            "parse configuration {}{line}: {}",
341            path.display(),
342            error.message()
343        )
344    })
345}
346
347pub(super) fn merge(base: &mut toml::Value, overlay: toml::Value) {
348    match (base, overlay) {
349        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
350            for (key, value) in overlay {
351                match base.get_mut(&key) {
352                    Some(existing) => merge(existing, value),
353                    None => {
354                        base.insert(key, value);
355                    }
356                }
357            }
358        }
359        (base, overlay) => *base = overlay,
360    }
361}
362
363/// A configuration value in effect and the layer that set it.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct Setting {
366    /// Dotted key, such as `tools.approval_policy`.
367    pub key: String,
368    /// The value as TOML, or `<hidden>` for a secret.
369    pub value: String,
370    /// `default`, `config.toml`, `project .scv/config.toml`, `SCV_CONFIG`,
371    /// `env <VARIABLE>`, or `--<name> flag`.
372    pub origin: String,
373}
374
375/// Call `visit` with every leaf of `value` under its dotted key.
376fn flatten(value: &toml::Value, prefix: String, visit: &mut impl FnMut(String, &toml::Value)) {
377    match value {
378        toml::Value::Table(table) => {
379            for (key, value) in table {
380                let key = if prefix.is_empty() {
381                    key.clone()
382                } else {
383                    format!("{prefix}.{key}")
384                };
385                flatten(value, key, visit);
386            }
387        }
388        leaf => visit(prefix, leaf),
389    }
390}
391
392/// Keys whose values are credentials: API keys, secrets, passwords, and
393/// provider headers, which commonly carry authorization.
394pub(super) fn is_secret_key(key: &str) -> bool {
395    let last = key.rsplit('.').next().unwrap_or(key);
396    last == "api_key"
397        || last.ends_with("_api_key")
398        || last.contains("secret")
399        || last.contains("password")
400        || key.split('.').any(|segment| segment == "headers")
401}
402
403fn expand_home(path: &std::path::Path) -> PathBuf {
404    let value = path.to_string_lossy();
405    if value == "~" {
406        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
407    }
408    if let Some(rest) = value.strip_prefix("~/")
409        && let Some(home) = dirs::home_dir()
410    {
411        return home.join(rest);
412    }
413    path.to_path_buf()
414}