1use 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 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 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 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 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 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 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
313pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct Setting {
363 pub key: String,
365 pub value: String,
367 pub origin: String,
370}
371
372fn 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
389pub(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}