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