Skip to main content

scv_server/
config.rs

1use std::{collections::HashMap, ffi::OsString, io::Write, path::PathBuf, time::Duration};
2
3use anyhow::{Context, Result, bail};
4use scv_core::{AgentConfig as CoreAgentConfig, ContextConfig, HistoryLimits};
5use scv_provider_openai::ProviderLimits;
6use scv_tools::{AgentAdapterConfig, ToolsConfig};
7use serde::{Deserialize, Serialize};
8
9const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12#[serde(default, deny_unknown_fields)]
13pub struct Config {
14    pub provider: ProviderConfig,
15    /// Named provider profiles. When non-empty, `provider.active` selects one.
16    pub providers: HashMap<String, ProviderConfig>,
17    pub provider_active: Option<String>,
18    pub agent: AgentConfig,
19    pub session: SessionConfig,
20    pub context: ContextConfigFile,
21    pub tools: ToolConfig,
22    pub protocol: ProtocolConfig,
23    pub tui: TuiConfig,
24    pub update: UpdateConfig,
25    pub provider_limits: ProviderLimitsFile,
26    pub skills: SkillsConfig,
27    pub agents: AgentsConfig,
28    /// The process-owned root used for sockets, credentials, skills, and adapters.
29    #[serde(skip)]
30    pub instance_home: PathBuf,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(default, deny_unknown_fields)]
35pub struct ProviderConfig {
36    pub active: Option<String>,
37    pub kind: String,
38    pub wire_api: String,
39    pub model: String,
40    pub base_url: String,
41    pub api_key: Option<String>,
42    pub api_key_env: Option<String>,
43    pub timeout_seconds: u64,
44    pub headers: HashMap<String, String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48#[serde(default, deny_unknown_fields)]
49pub struct UpdateConfig {
50    /// Optional Cargo registry index URL used by `scv update`.
51    pub index_url: Option<String>,
52}
53
54impl Default for ProviderConfig {
55    fn default() -> Self {
56        Self {
57            active: None,
58            kind: "openai-compatible".into(),
59            wire_api: "responses".into(),
60            model: "gpt-4.1-mini".into(),
61            base_url: "https://api.openai.com/v1".into(),
62            api_key: None,
63            api_key_env: Some("OPENAI_API_KEY".into()),
64            timeout_seconds: 120,
65            headers: HashMap::new(),
66        }
67    }
68}
69
70impl Config {
71    pub fn init_user_config() -> Result<PathBuf> {
72        let path = user_config_path()
73            .ok_or_else(|| anyhow::anyhow!("cannot determine user config path"))?;
74        if let Some(parent) = path.parent() {
75            std::fs::create_dir_all(parent).context("create config directory")?;
76            ensure_private_dir(parent)?;
77        }
78        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";
79        if !path.exists() {
80            let parent = path
81                .parent()
82                .ok_or_else(|| anyhow::anyhow!("configuration path has no parent"))?;
83            let mut temporary = tempfile::NamedTempFile::new_in(parent)
84                .context("create temporary example configuration")?;
85            #[cfg(unix)]
86            {
87                use std::os::unix::fs::PermissionsExt;
88                temporary
89                    .as_file()
90                    .set_permissions(std::fs::Permissions::from_mode(0o600))
91                    .context("secure temporary configuration")?;
92            }
93            temporary
94                .write_all(content.as_bytes())
95                .context("write example configuration")?;
96            temporary
97                .as_file()
98                .sync_all()
99                .context("sync example configuration")?;
100            match temporary.persist(&path) {
101                Ok(_) => {}
102                Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {}
103                Err(error) => return Err(error.error).context("install example configuration"),
104            }
105        }
106        Ok(path)
107    }
108    pub fn active_provider(&self) -> Result<ProviderConfig> {
109        if let Some(name) = self
110            .provider_active
111            .as_deref()
112            .or(self.provider.active.as_deref())
113        {
114            return self
115                .providers
116                .get(name)
117                .cloned()
118                .ok_or_else(|| anyhow::anyhow!("active provider profile {name:?} was not found"));
119        }
120        Ok(self.provider.clone())
121    }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(default, deny_unknown_fields)]
126pub struct AgentConfig {
127    pub max_steps: usize,
128    pub system_prompt: String,
129}
130
131impl Default for AgentConfig {
132    fn default() -> Self {
133        Self {
134            max_steps: 32,
135            system_prompt: "You are SCV, a concise and careful coding agent. Use tools to inspect, change, and verify the workspace.".into(),
136        }
137    }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default, deny_unknown_fields)]
142pub struct SessionConfig {
143    pub max_history_bytes: usize,
144    pub max_messages: usize,
145}
146
147impl Default for SessionConfig {
148    fn default() -> Self {
149        Self {
150            max_history_bytes: 16 * 1024 * 1024,
151            max_messages: 10_000,
152        }
153    }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(default, deny_unknown_fields)]
158pub struct ContextConfigFile {
159    pub max_tokens: usize,
160    pub reserve_output_tokens: usize,
161    pub safety_margin_tokens: usize,
162    pub bytes_per_token: usize,
163    pub summary_max_chars: usize,
164}
165
166impl Default for ContextConfigFile {
167    fn default() -> Self {
168        let value = ContextConfig::default();
169        Self {
170            max_tokens: value.max_tokens,
171            reserve_output_tokens: value.reserve_output_tokens,
172            safety_margin_tokens: value.safety_margin_tokens,
173            bytes_per_token: value.bytes_per_token,
174            summary_max_chars: value.summary_max_chars,
175        }
176    }
177}
178
179impl From<&ContextConfigFile> for ContextConfig {
180    fn from(value: &ContextConfigFile) -> Self {
181        Self {
182            max_tokens: value.max_tokens,
183            reserve_output_tokens: value.reserve_output_tokens,
184            safety_margin_tokens: value.safety_margin_tokens,
185            bytes_per_token: value.bytes_per_token,
186            summary_max_chars: value.summary_max_chars,
187        }
188    }
189}
190
191#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
192#[serde(rename_all = "kebab-case")]
193pub enum ApprovalPolicy {
194    OnRisk,
195    Always,
196    Never,
197}
198
199impl ApprovalPolicy {
200    fn strictness(self) -> u8 {
201        match self {
202            Self::OnRisk => 1,
203            Self::Always => 2,
204            Self::Never => 3,
205        }
206    }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(default, deny_unknown_fields)]
211pub struct ToolConfig {
212    pub approval_policy: ApprovalPolicy,
213    pub command_timeout_seconds: u64,
214    pub output_limit_bytes: usize,
215    pub max_read_bytes: usize,
216    pub max_write_bytes: usize,
217}
218
219impl Default for ToolConfig {
220    fn default() -> Self {
221        Self {
222            approval_policy: ApprovalPolicy::OnRisk,
223            command_timeout_seconds: 120,
224            output_limit_bytes: 64 * 1024,
225            max_read_bytes: 256 * 1024,
226            max_write_bytes: 1024 * 1024,
227        }
228    }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(default, deny_unknown_fields)]
233pub struct ProtocolConfig {
234    pub max_client_frame_bytes: usize,
235    pub max_server_frame_bytes: usize,
236}
237
238impl Default for ProtocolConfig {
239    fn default() -> Self {
240        Self {
241            max_client_frame_bytes: 1024 * 1024,
242            max_server_frame_bytes: 8 * 1024 * 1024,
243        }
244    }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(default, deny_unknown_fields)]
249pub struct TuiConfig {
250    pub max_transcript_bytes: usize,
251    pub max_transcript_items: usize,
252    pub max_prompt_history_bytes: usize,
253    pub max_prompt_history_items: usize,
254}
255
256impl Default for TuiConfig {
257    fn default() -> Self {
258        Self {
259            max_transcript_bytes: 8 * 1024 * 1024,
260            max_transcript_items: 10_000,
261            max_prompt_history_bytes: 1024 * 1024,
262            max_prompt_history_items: 200,
263        }
264    }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268#[serde(default, deny_unknown_fields)]
269pub struct ProviderLimitsFile {
270    pub max_sse_event_bytes: usize,
271    pub max_response_bytes: usize,
272    pub max_assistant_bytes: usize,
273    pub max_tool_calls: usize,
274    pub max_tool_arguments_bytes: usize,
275}
276
277impl Default for ProviderLimitsFile {
278    fn default() -> Self {
279        let value = ProviderLimits::default();
280        Self {
281            max_sse_event_bytes: value.max_sse_event_bytes,
282            max_response_bytes: value.max_response_bytes,
283            max_assistant_bytes: value.max_assistant_bytes,
284            max_tool_calls: value.max_tool_calls,
285            max_tool_arguments_bytes: value.max_tool_arguments_bytes,
286        }
287    }
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291#[serde(default, deny_unknown_fields)]
292pub struct SkillsConfig {
293    pub user_dir: PathBuf,
294    pub project_dir: PathBuf,
295    pub max_skills: usize,
296    pub max_skill_bytes: usize,
297}
298
299impl Default for SkillsConfig {
300    fn default() -> Self {
301        Self {
302            user_dir: PathBuf::from("~/.scv/skills"),
303            project_dir: PathBuf::from(".scv/skills"),
304            max_skills: 128,
305            max_skill_bytes: 256 * 1024,
306        }
307    }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, Default)]
311#[serde(default, deny_unknown_fields)]
312pub struct AdapterConfig {
313    pub command: String,
314    pub args: Vec<String>,
315    /// Appended when a call selects a model; `{model}` is substituted.
316    pub model_args: Vec<String>,
317    /// Appended when a call selects an effort; `{effort}` is substituted.
318    pub effort_args: Vec<String>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322#[serde(default, deny_unknown_fields)]
323pub struct AgentsConfig {
324    pub claude: AdapterConfig,
325    pub codex: AdapterConfig,
326    pub pi: AdapterConfig,
327}
328
329impl Default for AgentsConfig {
330    fn default() -> Self {
331        Self {
332            claude: AdapterConfig {
333                command: "claude".into(),
334                args: vec!["-p".into()],
335                model_args: vec!["--model".into(), "{model}".into()],
336                effort_args: vec!["--effort".into(), "{effort}".into()],
337            },
338            codex: AdapterConfig {
339                command: "codex".into(),
340                args: vec!["exec".into()],
341                model_args: vec!["-m".into(), "{model}".into()],
342                effort_args: vec!["-c".into(), "model_reasoning_effort=\"{effort}\"".into()],
343            },
344            pi: AdapterConfig {
345                command: "pi".into(),
346                args: vec!["-p".into()],
347                model_args: Vec::new(),
348                effort_args: Vec::new(),
349            },
350        }
351    }
352}
353
354#[derive(Debug, Clone, Default)]
355pub struct ConfigOverrides {
356    pub provider: Option<String>,
357    pub model: Option<String>,
358    pub base_url: Option<String>,
359    pub approval_policy: Option<ApprovalPolicy>,
360    pub no_tools: bool,
361}
362
363impl Config {
364    pub fn load(workspace: &std::path::Path, overrides: ConfigOverrides) -> Result<Self> {
365        Self::load_layers(Some(workspace), overrides)
366    }
367
368    /// Load without a project layer, for settings that project configuration
369    /// can never set (such as `[agents]`), so the caller's directory is irrelevant.
370    pub fn load_user(overrides: ConfigOverrides) -> Result<Self> {
371        Self::load_layers(None, overrides)
372    }
373
374    fn load_layers(
375        workspace: Option<&std::path::Path>,
376        overrides: ConfigOverrides,
377    ) -> Result<Self> {
378        let instance_home = user_home_path()
379            .ok_or_else(|| anyhow::anyhow!("cannot determine SCV instance home"))?;
380        std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
381        ensure_private_dir(&instance_home)?;
382        let mut value: toml::Value = toml::from_str(
383            &toml::to_string(&Self::default()).context("serialize default configuration")?,
384        )?;
385
386        if let Some(user_path) = user_config_path()
387            && user_path.is_file()
388        {
389            #[cfg(unix)]
390            {
391                use std::os::unix::fs::PermissionsExt;
392                if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
393                    bail!("user configuration is readable by group or others; run chmod 600");
394                }
395            }
396            merge(&mut value, read_layer(&user_path)?);
397        }
398        let user_baseline: Self = value
399            .clone()
400            .try_into()
401            .context("parse user configuration")?;
402
403        if let Some(workspace) = workspace {
404            let project_path = workspace.join(".scv/config.toml");
405            // A workspace whose `.scv` is the SCV home (such as running from
406            // `~`) has no project layer: that file is the user configuration,
407            // already applied above at full trust.
408            let user_file = user_config_path().and_then(|path| std::fs::canonicalize(path).ok());
409            if project_path.is_file() {
410                let canonical_project = std::fs::canonicalize(&project_path)
411                    .with_context(|| format!("resolve configuration {}", project_path.display()))?;
412                if user_file.as_ref() != Some(&canonical_project) {
413                    if !canonical_project.starts_with(workspace) {
414                        bail!("project configuration escaped workspace");
415                    }
416                    let project = read_layer(&canonical_project)?;
417                    validate_project_keys(&project)?;
418                    let mut candidate_value = value.clone();
419                    merge(&mut candidate_value, project);
420                    let candidate: Self = candidate_value
421                        .clone()
422                        .try_into()
423                        .context("parse project configuration")?;
424                    validate_project_not_weaker(&user_baseline, &candidate)?;
425                    value = candidate_value;
426                }
427            }
428        }
429
430        if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
431            let path = PathBuf::from(explicit);
432            #[cfg(unix)]
433            {
434                use std::os::unix::fs::PermissionsExt;
435                if std::fs::metadata(&path)?.permissions().mode() & 0o077 != 0 {
436                    bail!("explicit configuration is readable by group or others; run chmod 600");
437                }
438            }
439            merge(&mut value, read_layer(&path)?);
440        }
441        let mut config: Self = value.try_into().context("parse merged configuration")?;
442        if let Some(name) = overrides.provider.as_deref() {
443            config.provider_active = Some(name.to_owned());
444        }
445        let selected = config.active_provider()?;
446        config.provider = selected;
447        if let Ok(model) = std::env::var("SCV_MODEL") {
448            config.provider.model = model;
449        }
450        if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
451            config.provider.base_url = base_url;
452        }
453        if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
454            config.provider.api_key_env = Some(api_key_env);
455        }
456        if let Some(model) = overrides.model {
457            config.provider.model = model;
458        }
459        if let Some(base_url) = overrides.base_url {
460            config.provider.base_url = base_url;
461        }
462        if let Some(policy) = overrides.approval_policy {
463            config.tools.approval_policy = policy;
464        }
465        if config.skills.user_dir == std::path::Path::new("~/.scv/skills")
466            && let Some(home) = std::env::var_os("SCV_HOME")
467        {
468            config.skills.user_dir = PathBuf::from(home).join("skills");
469        }
470        config.skills.user_dir = expand_home(&config.skills.user_dir);
471        config.instance_home = instance_home;
472        config.validate()?;
473        Ok(config)
474    }
475
476    pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
477        CoreAgentConfig {
478            system_prompt,
479            max_steps: self.agent.max_steps,
480            history_limits: HistoryLimits {
481                max_bytes: self.session.max_history_bytes,
482                max_messages: self.session.max_messages,
483                note_max_chars: self.context.summary_max_chars,
484            },
485        }
486    }
487
488    pub fn tools(&self) -> ToolsConfig {
489        ToolsConfig {
490            command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
491            output_limit_bytes: self.tools.output_limit_bytes,
492            max_read_bytes: self.tools.max_read_bytes,
493            max_write_bytes: self.tools.max_write_bytes,
494        }
495    }
496
497    pub fn provider_limits(&self) -> ProviderLimits {
498        ProviderLimits {
499            max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
500            max_response_bytes: self.provider_limits.max_response_bytes,
501            max_assistant_bytes: self.provider_limits.max_assistant_bytes,
502            max_tool_calls: self.provider_limits.max_tool_calls,
503            max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
504        }
505    }
506
507    pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
508        [
509            ("agent_claude", &self.agents.claude),
510            ("agent_codex", &self.agents.codex),
511            ("agent_pi", &self.agents.pi),
512        ]
513        .into_iter()
514        .map(|(name, config)| {
515            let adapter_name = name.strip_prefix("agent_").unwrap_or(name);
516            let adapter_home = self.instance_home.join("adapters").join(adapter_name);
517            let mut environment = vec![
518                (OsString::from("SCV_HOME"), adapter_home.clone().into()),
519                (OsString::from("HOME"), adapter_home.clone().into()),
520                (
521                    OsString::from("XDG_CONFIG_HOME"),
522                    adapter_home.join("config").into(),
523                ),
524                (
525                    OsString::from("XDG_DATA_HOME"),
526                    adapter_home.join("data").into(),
527                ),
528                (
529                    OsString::from("XDG_STATE_HOME"),
530                    adapter_home.join("state").into(),
531                ),
532            ];
533            if adapter_name == "codex" {
534                environment.push((OsString::from("CODEX_HOME"), adapter_home.clone().into()));
535            }
536            (
537                name.to_owned(),
538                AgentAdapterConfig {
539                    command: config.command.clone(),
540                    args: config.args.clone(),
541                    model_args: config.model_args.clone(),
542                    effort_args: config.effort_args.clone(),
543                    environment,
544                },
545            )
546        })
547        .collect()
548    }
549
550    pub fn prepare_adapter_homes(&self) -> Result<()> {
551        for name in ["claude", "codex", "pi"] {
552            let path = self.instance_home.join("adapters").join(name);
553            std::fs::create_dir_all(&path)
554                .with_context(|| format!("create isolated {name} adapter home"))?;
555            #[cfg(unix)]
556            {
557                use std::os::unix::fs::PermissionsExt;
558                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
559                    .with_context(|| format!("secure isolated {name} adapter home"))?;
560            }
561        }
562        Ok(())
563    }
564
565    fn validate(&self) -> Result<()> {
566        if self.provider.kind != "openai-compatible" {
567            bail!("provider.kind must be openai-compatible in v0.1");
568        }
569        if self.provider.model.trim().is_empty()
570            || self.provider.base_url.trim().is_empty()
571            || self
572                .provider
573                .api_key
574                .as_deref()
575                .unwrap_or("")
576                .trim()
577                .is_empty()
578                && self
579                    .provider
580                    .api_key_env
581                    .as_deref()
582                    .unwrap_or("")
583                    .trim()
584                    .is_empty()
585        {
586            bail!(
587                "provider model and base_url must be non-empty; configure api_key or api_key_env"
588            );
589        }
590        for (name, adapter) in [
591            ("agents.claude.command", &self.agents.claude),
592            ("agents.codex.command", &self.agents.codex),
593            ("agents.pi.command", &self.agents.pi),
594        ] {
595            if adapter.command.trim().is_empty() {
596                bail!("{name} must be non-empty");
597            }
598            for (field, template, placeholder) in [
599                ("model_args", &adapter.model_args, "{model}"),
600                ("effort_args", &adapter.effort_args, "{effort}"),
601            ] {
602                if !template.is_empty() && !template.iter().any(|arg| arg.contains(placeholder)) {
603                    let adapter = name.trim_end_matches(".command");
604                    bail!("{adapter}.{field} must contain {placeholder} or be empty");
605                }
606            }
607            let adapter_bytes = adapter.command.len()
608                + [&adapter.args, &adapter.model_args, &adapter.effort_args]
609                    .into_iter()
610                    .flatten()
611                    .map(String::len)
612                    .sum::<usize>();
613            if adapter_bytes > 16 * 1024 {
614                bail!("{name} and its fixed arguments exceed 16384 bytes");
615            }
616        }
617        let positives = [
618            (
619                "provider.timeout_seconds",
620                usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
621            ),
622            ("agent.max_steps", self.agent.max_steps),
623            ("session.max_history_bytes", self.session.max_history_bytes),
624            ("session.max_messages", self.session.max_messages),
625            ("context.max_tokens", self.context.max_tokens),
626            ("context.bytes_per_token", self.context.bytes_per_token),
627            ("context.summary_max_chars", self.context.summary_max_chars),
628            (
629                "tools.command_timeout_seconds",
630                usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
631            ),
632            ("tools.output_limit_bytes", self.tools.output_limit_bytes),
633            ("tools.max_read_bytes", self.tools.max_read_bytes),
634            ("tools.max_write_bytes", self.tools.max_write_bytes),
635            (
636                "protocol.max_client_frame_bytes",
637                self.protocol.max_client_frame_bytes,
638            ),
639            (
640                "protocol.max_server_frame_bytes",
641                self.protocol.max_server_frame_bytes,
642            ),
643            ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
644            ("tui.max_transcript_items", self.tui.max_transcript_items),
645            (
646                "tui.max_prompt_history_bytes",
647                self.tui.max_prompt_history_bytes,
648            ),
649            (
650                "tui.max_prompt_history_items",
651                self.tui.max_prompt_history_items,
652            ),
653            (
654                "provider_limits.max_sse_event_bytes",
655                self.provider_limits.max_sse_event_bytes,
656            ),
657            (
658                "provider_limits.max_response_bytes",
659                self.provider_limits.max_response_bytes,
660            ),
661            (
662                "provider_limits.max_assistant_bytes",
663                self.provider_limits.max_assistant_bytes,
664            ),
665            (
666                "provider_limits.max_tool_calls",
667                self.provider_limits.max_tool_calls,
668            ),
669            (
670                "provider_limits.max_tool_arguments_bytes",
671                self.provider_limits.max_tool_arguments_bytes,
672            ),
673            ("skills.max_skills", self.skills.max_skills),
674            ("skills.max_skill_bytes", self.skills.max_skill_bytes),
675        ];
676        if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
677            bail!("{name} must be positive");
678        }
679        if self
680            .context
681            .reserve_output_tokens
682            .saturating_add(self.context.safety_margin_tokens)
683            >= self.context.max_tokens
684        {
685            bail!("context reserve and safety margin consume max_tokens");
686        }
687        let worst_assistant_frame = self
688            .provider_limits
689            .max_assistant_bytes
690            .saturating_mul(6)
691            .saturating_add(64 * 1024);
692        if worst_assistant_frame > self.protocol.max_server_frame_bytes {
693            bail!(
694                "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
695            );
696        }
697        if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
698            bail!("tool argument limit exceeds provider response limit");
699        }
700        if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
701            bail!("provider SSE event limit exceeds provider response limit");
702        }
703        if self.protocol.max_client_frame_bytes < 4096 {
704            bail!("protocol.max_client_frame_bytes must be at least 4096");
705        }
706        if self.protocol.max_server_frame_bytes < 64 * 1024 {
707            bail!("protocol.max_server_frame_bytes must be at least 65536");
708        }
709        let worst_tool_frame = self
710            .tools
711            .output_limit_bytes
712            .max(self.tools.max_read_bytes)
713            .saturating_mul(12)
714            .saturating_add(64 * 1024);
715        let worst_skill_frame = self
716            .skills
717            .max_skill_bytes
718            .saturating_mul(6)
719            .saturating_add(64 * 1024);
720        let worst_arguments_frame = self
721            .provider_limits
722            .max_tool_arguments_bytes
723            .saturating_mul(6)
724            .saturating_add(64 * 1024);
725        if worst_tool_frame
726            .max(worst_skill_frame)
727            .max(worst_arguments_frame)
728            > self.protocol.max_server_frame_bytes
729        {
730            bail!(
731                "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
732            );
733        }
734        if self.skills.project_dir.is_absolute()
735            || self
736                .skills
737                .project_dir
738                .components()
739                .any(|component| matches!(component, std::path::Component::ParentDir))
740        {
741            bail!("skills.project_dir must be a contained relative path");
742        }
743        Ok(())
744    }
745}
746
747fn user_config_path() -> Option<PathBuf> {
748    user_home_path().map(|path| path.join("config.toml"))
749}
750
751pub fn user_home_path() -> Option<PathBuf> {
752    let path = std::env::var_os("SCV_HOME")
753        .map(PathBuf::from)
754        .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))?;
755    if path.exists() {
756        Some(std::fs::canonicalize(path.clone()).unwrap_or(path))
757    } else if path.is_absolute() {
758        Some(path)
759    } else {
760        std::env::current_dir().ok().map(|cwd| cwd.join(path))
761    }
762}
763
764fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
765    #[cfg(unix)]
766    {
767        use std::os::unix::fs::PermissionsExt;
768        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
769            .with_context(|| format!("secure directory {}", path.display()))?;
770    }
771    Ok(())
772}
773
774fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
775    let size = std::fs::metadata(path)
776        .with_context(|| format!("stat configuration {}", path.display()))?
777        .len();
778    if size > MAX_CONFIG_BYTES {
779        bail!("configuration {} exceeds 1 MiB", path.display());
780    }
781    let content = std::fs::read_to_string(path)
782        .with_context(|| format!("read configuration {}", path.display()))?;
783    toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
784}
785
786fn merge(base: &mut toml::Value, overlay: toml::Value) {
787    match (base, overlay) {
788        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
789            for (key, value) in overlay {
790                match base.get_mut(&key) {
791                    Some(existing) => merge(existing, value),
792                    None => {
793                        base.insert(key, value);
794                    }
795                }
796            }
797        }
798        (base, overlay) => *base = overlay,
799    }
800}
801
802fn validate_project_keys(value: &toml::Value) -> Result<()> {
803    let Some(table) = value.as_table() else {
804        bail!("project configuration must be a TOML table");
805    };
806    for forbidden in [
807        "provider",
808        "providers",
809        "provider_active",
810        "agents",
811        "update",
812    ] {
813        if table.contains_key(forbidden) {
814            bail!("project configuration cannot set [{forbidden}]");
815        }
816    }
817    if table
818        .get("skills")
819        .and_then(toml::Value::as_table)
820        .is_some_and(|skills| skills.contains_key("user_dir"))
821    {
822        bail!("project configuration cannot set skills.user_dir");
823    }
824    if table
825        .get("agent")
826        .and_then(toml::Value::as_table)
827        .is_some_and(|agent| agent.contains_key("system_prompt"))
828    {
829        bail!("project configuration cannot replace agent.system_prompt");
830    }
831    Ok(())
832}
833
834fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
835    macro_rules! no_larger {
836        ($field:expr, $name:literal) => {
837            if $field.1 > $field.0 {
838                bail!(concat!("project configuration cannot raise ", $name));
839            }
840        };
841    }
842    no_larger!(
843        (user.agent.max_steps, project.agent.max_steps),
844        "agent.max_steps"
845    );
846    no_larger!(
847        (
848            user.session.max_history_bytes,
849            project.session.max_history_bytes
850        ),
851        "session.max_history_bytes"
852    );
853    no_larger!(
854        (user.session.max_messages, project.session.max_messages),
855        "session.max_messages"
856    );
857    no_larger!(
858        (user.context.max_tokens, project.context.max_tokens),
859        "context.max_tokens"
860    );
861    no_larger!(
862        (
863            user.context.summary_max_chars,
864            project.context.summary_max_chars
865        ),
866        "context.summary_max_chars"
867    );
868    no_larger!(
869        (
870            user.tools.command_timeout_seconds,
871            project.tools.command_timeout_seconds
872        ),
873        "tools.command_timeout_seconds"
874    );
875    no_larger!(
876        (
877            user.tools.output_limit_bytes,
878            project.tools.output_limit_bytes
879        ),
880        "tools.output_limit_bytes"
881    );
882    no_larger!(
883        (user.tools.max_read_bytes, project.tools.max_read_bytes),
884        "tools.max_read_bytes"
885    );
886    no_larger!(
887        (user.tools.max_write_bytes, project.tools.max_write_bytes),
888        "tools.max_write_bytes"
889    );
890    no_larger!(
891        (
892            user.protocol.max_client_frame_bytes,
893            project.protocol.max_client_frame_bytes
894        ),
895        "protocol.max_client_frame_bytes"
896    );
897    no_larger!(
898        (
899            user.protocol.max_server_frame_bytes,
900            project.protocol.max_server_frame_bytes
901        ),
902        "protocol.max_server_frame_bytes"
903    );
904    no_larger!(
905        (
906            user.provider_limits.max_response_bytes,
907            project.provider_limits.max_response_bytes
908        ),
909        "provider_limits.max_response_bytes"
910    );
911    no_larger!(
912        (
913            user.provider_limits.max_sse_event_bytes,
914            project.provider_limits.max_sse_event_bytes
915        ),
916        "provider_limits.max_sse_event_bytes"
917    );
918    no_larger!(
919        (
920            user.provider_limits.max_assistant_bytes,
921            project.provider_limits.max_assistant_bytes
922        ),
923        "provider_limits.max_assistant_bytes"
924    );
925    no_larger!(
926        (
927            user.provider_limits.max_tool_calls,
928            project.provider_limits.max_tool_calls
929        ),
930        "provider_limits.max_tool_calls"
931    );
932    no_larger!(
933        (
934            user.provider_limits.max_tool_arguments_bytes,
935            project.provider_limits.max_tool_arguments_bytes
936        ),
937        "provider_limits.max_tool_arguments_bytes"
938    );
939    no_larger!(
940        (
941            user.tui.max_transcript_bytes,
942            project.tui.max_transcript_bytes
943        ),
944        "tui.max_transcript_bytes"
945    );
946    no_larger!(
947        (
948            user.tui.max_transcript_items,
949            project.tui.max_transcript_items
950        ),
951        "tui.max_transcript_items"
952    );
953    no_larger!(
954        (
955            user.tui.max_prompt_history_bytes,
956            project.tui.max_prompt_history_bytes
957        ),
958        "tui.max_prompt_history_bytes"
959    );
960    no_larger!(
961        (
962            user.tui.max_prompt_history_items,
963            project.tui.max_prompt_history_items
964        ),
965        "tui.max_prompt_history_items"
966    );
967    no_larger!(
968        (user.skills.max_skills, project.skills.max_skills),
969        "skills.max_skills"
970    );
971    no_larger!(
972        (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
973        "skills.max_skill_bytes"
974    );
975    if project.context.reserve_output_tokens < user.context.reserve_output_tokens
976        || project.context.safety_margin_tokens < user.context.safety_margin_tokens
977    {
978        bail!("project configuration cannot lower context reserves");
979    }
980    if project.context.bytes_per_token > user.context.bytes_per_token {
981        bail!("project configuration cannot raise context.bytes_per_token");
982    }
983    if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
984        bail!("project configuration cannot weaken tools.approval_policy");
985    }
986    Ok(())
987}
988
989fn expand_home(path: &std::path::Path) -> PathBuf {
990    let value = path.to_string_lossy();
991    if value == "~" {
992        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
993    }
994    if let Some(rest) = value.strip_prefix("~/")
995        && let Some(home) = dirs::home_dir()
996    {
997        return home.join(rest);
998    }
999    path.to_path_buf()
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005
1006    #[test]
1007    fn project_cannot_redirect_provider_or_agent() {
1008        let provider: toml::Value = toml::from_str(
1009            r#"[provider]
1010base_url = "https://attacker.invalid"
1011"#,
1012        )
1013        .unwrap();
1014        assert!(validate_project_keys(&provider).is_err());
1015
1016        let agent: toml::Value = toml::from_str(
1017            r#"[agents.codex]
1018command = "/tmp/fake"
1019"#,
1020        )
1021        .unwrap();
1022        assert!(validate_project_keys(&agent).is_err());
1023    }
1024
1025    #[test]
1026    fn project_may_tighten_but_not_weaken_limits() {
1027        let user = Config::default();
1028        let mut tighter = user.clone();
1029        tighter.tools.output_limit_bytes /= 2;
1030        tighter.tools.approval_policy = ApprovalPolicy::Always;
1031        assert!(validate_project_not_weaker(&user, &tighter).is_ok());
1032
1033        let mut weaker = user.clone();
1034        weaker.tools.output_limit_bytes *= 2;
1035        assert!(validate_project_not_weaker(&user, &weaker).is_err());
1036    }
1037
1038    #[test]
1039    fn cross_field_validation_accounts_for_json_escaping() {
1040        let mut config = Config::default();
1041        config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
1042        assert!(config.validate().is_err());
1043    }
1044
1045    #[test]
1046    fn adapter_selection_templates_survive_partial_overrides_and_validate() {
1047        let mut value: toml::Value =
1048            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1049        merge(
1050            &mut value,
1051            toml::from_str(
1052                r#"[agents.claude]
1053args = ["-p", "--permission-mode", "acceptEdits"]
1054"#,
1055            )
1056            .unwrap(),
1057        );
1058        let config: Config = value.try_into().unwrap();
1059        assert_eq!(config.agents.claude.args.len(), 3);
1060        assert_eq!(config.agents.claude.model_args, ["--model", "{model}"]);
1061        assert_eq!(config.agents.claude.effort_args, ["--effort", "{effort}"]);
1062        assert!(config.agents.pi.model_args.is_empty());
1063
1064        let mut invalid = Config::default();
1065        invalid.agents.claude.effort_args = vec!["--effort".into()];
1066        assert!(
1067            invalid
1068                .validate()
1069                .unwrap_err()
1070                .to_string()
1071                .contains("agents.claude.effort_args must contain {effort}")
1072        );
1073    }
1074
1075    #[test]
1076    fn adapters_are_bound_to_the_instance_home() {
1077        let config = Config {
1078            instance_home: PathBuf::from("/tmp/scv-instance"),
1079            ..Config::default()
1080        };
1081        let adapters = config.adapters();
1082        let codex = &adapters["agent_codex"];
1083        assert!(codex.environment.contains(&(
1084            OsString::from("CODEX_HOME"),
1085            OsString::from("/tmp/scv-instance/adapters/codex")
1086        )));
1087        assert!(codex.environment.contains(&(
1088            OsString::from("SCV_HOME"),
1089            OsString::from("/tmp/scv-instance/adapters/codex")
1090        )));
1091    }
1092}