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        let instance_home = user_home_path()
366            .ok_or_else(|| anyhow::anyhow!("cannot determine SCV instance home"))?;
367        std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
368        ensure_private_dir(&instance_home)?;
369        let mut value: toml::Value = toml::from_str(
370            &toml::to_string(&Self::default()).context("serialize default configuration")?,
371        )?;
372
373        if let Some(user_path) = user_config_path()
374            && user_path.is_file()
375        {
376            #[cfg(unix)]
377            {
378                use std::os::unix::fs::PermissionsExt;
379                if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
380                    bail!("user configuration is readable by group or others; run chmod 600");
381                }
382            }
383            merge(&mut value, read_layer(&user_path)?);
384        }
385        let user_baseline: Self = value
386            .clone()
387            .try_into()
388            .context("parse user configuration")?;
389
390        let project_path = workspace.join(".scv/config.toml");
391        if project_path.is_file() {
392            let canonical_project = std::fs::canonicalize(&project_path)
393                .with_context(|| format!("resolve configuration {}", project_path.display()))?;
394            if !canonical_project.starts_with(workspace) {
395                bail!("project configuration escaped workspace");
396            }
397            let project = read_layer(&canonical_project)?;
398            validate_project_keys(&project)?;
399            let mut candidate_value = value.clone();
400            merge(&mut candidate_value, project);
401            let candidate: Self = candidate_value
402                .clone()
403                .try_into()
404                .context("parse project configuration")?;
405            validate_project_not_weaker(&user_baseline, &candidate)?;
406            value = candidate_value;
407        }
408
409        if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
410            let path = PathBuf::from(explicit);
411            #[cfg(unix)]
412            {
413                use std::os::unix::fs::PermissionsExt;
414                if std::fs::metadata(&path)?.permissions().mode() & 0o077 != 0 {
415                    bail!("explicit configuration is readable by group or others; run chmod 600");
416                }
417            }
418            merge(&mut value, read_layer(&path)?);
419        }
420        let mut config: Self = value.try_into().context("parse merged configuration")?;
421        if let Some(name) = overrides.provider.as_deref() {
422            config.provider_active = Some(name.to_owned());
423        }
424        let selected = config.active_provider()?;
425        config.provider = selected;
426        if let Ok(model) = std::env::var("SCV_MODEL") {
427            config.provider.model = model;
428        }
429        if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
430            config.provider.base_url = base_url;
431        }
432        if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
433            config.provider.api_key_env = Some(api_key_env);
434        }
435        if let Some(model) = overrides.model {
436            config.provider.model = model;
437        }
438        if let Some(base_url) = overrides.base_url {
439            config.provider.base_url = base_url;
440        }
441        if let Some(policy) = overrides.approval_policy {
442            config.tools.approval_policy = policy;
443        }
444        if config.skills.user_dir == std::path::Path::new("~/.scv/skills")
445            && let Some(home) = std::env::var_os("SCV_HOME")
446        {
447            config.skills.user_dir = PathBuf::from(home).join("skills");
448        }
449        config.skills.user_dir = expand_home(&config.skills.user_dir);
450        config.instance_home = instance_home;
451        config.validate()?;
452        Ok(config)
453    }
454
455    pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
456        CoreAgentConfig {
457            system_prompt,
458            max_steps: self.agent.max_steps,
459            history_limits: HistoryLimits {
460                max_bytes: self.session.max_history_bytes,
461                max_messages: self.session.max_messages,
462                note_max_chars: self.context.summary_max_chars,
463            },
464        }
465    }
466
467    pub fn tools(&self) -> ToolsConfig {
468        ToolsConfig {
469            command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
470            output_limit_bytes: self.tools.output_limit_bytes,
471            max_read_bytes: self.tools.max_read_bytes,
472            max_write_bytes: self.tools.max_write_bytes,
473        }
474    }
475
476    pub fn provider_limits(&self) -> ProviderLimits {
477        ProviderLimits {
478            max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
479            max_response_bytes: self.provider_limits.max_response_bytes,
480            max_assistant_bytes: self.provider_limits.max_assistant_bytes,
481            max_tool_calls: self.provider_limits.max_tool_calls,
482            max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
483        }
484    }
485
486    pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
487        [
488            ("agent_claude", &self.agents.claude),
489            ("agent_codex", &self.agents.codex),
490            ("agent_pi", &self.agents.pi),
491        ]
492        .into_iter()
493        .map(|(name, config)| {
494            let adapter_name = name.strip_prefix("agent_").unwrap_or(name);
495            let adapter_home = self.instance_home.join("adapters").join(adapter_name);
496            let mut environment = vec![
497                (OsString::from("SCV_HOME"), adapter_home.clone().into()),
498                (OsString::from("HOME"), adapter_home.clone().into()),
499                (
500                    OsString::from("XDG_CONFIG_HOME"),
501                    adapter_home.join("config").into(),
502                ),
503                (
504                    OsString::from("XDG_DATA_HOME"),
505                    adapter_home.join("data").into(),
506                ),
507                (
508                    OsString::from("XDG_STATE_HOME"),
509                    adapter_home.join("state").into(),
510                ),
511            ];
512            if adapter_name == "codex" {
513                environment.push((OsString::from("CODEX_HOME"), adapter_home.clone().into()));
514            }
515            (
516                name.to_owned(),
517                AgentAdapterConfig {
518                    command: config.command.clone(),
519                    args: config.args.clone(),
520                    model_args: config.model_args.clone(),
521                    effort_args: config.effort_args.clone(),
522                    environment,
523                },
524            )
525        })
526        .collect()
527    }
528
529    pub fn prepare_adapter_homes(&self) -> Result<()> {
530        for name in ["claude", "codex", "pi"] {
531            let path = self.instance_home.join("adapters").join(name);
532            std::fs::create_dir_all(&path)
533                .with_context(|| format!("create isolated {name} adapter home"))?;
534            #[cfg(unix)]
535            {
536                use std::os::unix::fs::PermissionsExt;
537                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
538                    .with_context(|| format!("secure isolated {name} adapter home"))?;
539            }
540        }
541        Ok(())
542    }
543
544    fn validate(&self) -> Result<()> {
545        if self.provider.kind != "openai-compatible" {
546            bail!("provider.kind must be openai-compatible in v0.1");
547        }
548        if self.provider.model.trim().is_empty()
549            || self.provider.base_url.trim().is_empty()
550            || self
551                .provider
552                .api_key
553                .as_deref()
554                .unwrap_or("")
555                .trim()
556                .is_empty()
557                && self
558                    .provider
559                    .api_key_env
560                    .as_deref()
561                    .unwrap_or("")
562                    .trim()
563                    .is_empty()
564        {
565            bail!(
566                "provider model and base_url must be non-empty; configure api_key or api_key_env"
567            );
568        }
569        for (name, adapter) in [
570            ("agents.claude.command", &self.agents.claude),
571            ("agents.codex.command", &self.agents.codex),
572            ("agents.pi.command", &self.agents.pi),
573        ] {
574            if adapter.command.trim().is_empty() {
575                bail!("{name} must be non-empty");
576            }
577            for (field, template, placeholder) in [
578                ("model_args", &adapter.model_args, "{model}"),
579                ("effort_args", &adapter.effort_args, "{effort}"),
580            ] {
581                if !template.is_empty() && !template.iter().any(|arg| arg.contains(placeholder)) {
582                    let adapter = name.trim_end_matches(".command");
583                    bail!("{adapter}.{field} must contain {placeholder} or be empty");
584                }
585            }
586            let adapter_bytes = adapter.command.len()
587                + [&adapter.args, &adapter.model_args, &adapter.effort_args]
588                    .into_iter()
589                    .flatten()
590                    .map(String::len)
591                    .sum::<usize>();
592            if adapter_bytes > 16 * 1024 {
593                bail!("{name} and its fixed arguments exceed 16384 bytes");
594            }
595        }
596        let positives = [
597            (
598                "provider.timeout_seconds",
599                usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
600            ),
601            ("agent.max_steps", self.agent.max_steps),
602            ("session.max_history_bytes", self.session.max_history_bytes),
603            ("session.max_messages", self.session.max_messages),
604            ("context.max_tokens", self.context.max_tokens),
605            ("context.bytes_per_token", self.context.bytes_per_token),
606            ("context.summary_max_chars", self.context.summary_max_chars),
607            (
608                "tools.command_timeout_seconds",
609                usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
610            ),
611            ("tools.output_limit_bytes", self.tools.output_limit_bytes),
612            ("tools.max_read_bytes", self.tools.max_read_bytes),
613            ("tools.max_write_bytes", self.tools.max_write_bytes),
614            (
615                "protocol.max_client_frame_bytes",
616                self.protocol.max_client_frame_bytes,
617            ),
618            (
619                "protocol.max_server_frame_bytes",
620                self.protocol.max_server_frame_bytes,
621            ),
622            ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
623            ("tui.max_transcript_items", self.tui.max_transcript_items),
624            (
625                "tui.max_prompt_history_bytes",
626                self.tui.max_prompt_history_bytes,
627            ),
628            (
629                "tui.max_prompt_history_items",
630                self.tui.max_prompt_history_items,
631            ),
632            (
633                "provider_limits.max_sse_event_bytes",
634                self.provider_limits.max_sse_event_bytes,
635            ),
636            (
637                "provider_limits.max_response_bytes",
638                self.provider_limits.max_response_bytes,
639            ),
640            (
641                "provider_limits.max_assistant_bytes",
642                self.provider_limits.max_assistant_bytes,
643            ),
644            (
645                "provider_limits.max_tool_calls",
646                self.provider_limits.max_tool_calls,
647            ),
648            (
649                "provider_limits.max_tool_arguments_bytes",
650                self.provider_limits.max_tool_arguments_bytes,
651            ),
652            ("skills.max_skills", self.skills.max_skills),
653            ("skills.max_skill_bytes", self.skills.max_skill_bytes),
654        ];
655        if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
656            bail!("{name} must be positive");
657        }
658        if self
659            .context
660            .reserve_output_tokens
661            .saturating_add(self.context.safety_margin_tokens)
662            >= self.context.max_tokens
663        {
664            bail!("context reserve and safety margin consume max_tokens");
665        }
666        let worst_assistant_frame = self
667            .provider_limits
668            .max_assistant_bytes
669            .saturating_mul(6)
670            .saturating_add(64 * 1024);
671        if worst_assistant_frame > self.protocol.max_server_frame_bytes {
672            bail!(
673                "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
674            );
675        }
676        if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
677            bail!("tool argument limit exceeds provider response limit");
678        }
679        if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
680            bail!("provider SSE event limit exceeds provider response limit");
681        }
682        if self.protocol.max_client_frame_bytes < 4096 {
683            bail!("protocol.max_client_frame_bytes must be at least 4096");
684        }
685        if self.protocol.max_server_frame_bytes < 64 * 1024 {
686            bail!("protocol.max_server_frame_bytes must be at least 65536");
687        }
688        let worst_tool_frame = self
689            .tools
690            .output_limit_bytes
691            .max(self.tools.max_read_bytes)
692            .saturating_mul(12)
693            .saturating_add(64 * 1024);
694        let worst_skill_frame = self
695            .skills
696            .max_skill_bytes
697            .saturating_mul(6)
698            .saturating_add(64 * 1024);
699        let worst_arguments_frame = self
700            .provider_limits
701            .max_tool_arguments_bytes
702            .saturating_mul(6)
703            .saturating_add(64 * 1024);
704        if worst_tool_frame
705            .max(worst_skill_frame)
706            .max(worst_arguments_frame)
707            > self.protocol.max_server_frame_bytes
708        {
709            bail!(
710                "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
711            );
712        }
713        if self.skills.project_dir.is_absolute()
714            || self
715                .skills
716                .project_dir
717                .components()
718                .any(|component| matches!(component, std::path::Component::ParentDir))
719        {
720            bail!("skills.project_dir must be a contained relative path");
721        }
722        Ok(())
723    }
724}
725
726fn user_config_path() -> Option<PathBuf> {
727    user_home_path().map(|path| path.join("config.toml"))
728}
729
730pub fn user_home_path() -> Option<PathBuf> {
731    let path = std::env::var_os("SCV_HOME")
732        .map(PathBuf::from)
733        .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))?;
734    if path.exists() {
735        Some(std::fs::canonicalize(path.clone()).unwrap_or(path))
736    } else if path.is_absolute() {
737        Some(path)
738    } else {
739        std::env::current_dir().ok().map(|cwd| cwd.join(path))
740    }
741}
742
743fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
744    #[cfg(unix)]
745    {
746        use std::os::unix::fs::PermissionsExt;
747        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
748            .with_context(|| format!("secure directory {}", path.display()))?;
749    }
750    Ok(())
751}
752
753fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
754    let size = std::fs::metadata(path)
755        .with_context(|| format!("stat configuration {}", path.display()))?
756        .len();
757    if size > MAX_CONFIG_BYTES {
758        bail!("configuration {} exceeds 1 MiB", path.display());
759    }
760    let content = std::fs::read_to_string(path)
761        .with_context(|| format!("read configuration {}", path.display()))?;
762    toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
763}
764
765fn merge(base: &mut toml::Value, overlay: toml::Value) {
766    match (base, overlay) {
767        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
768            for (key, value) in overlay {
769                match base.get_mut(&key) {
770                    Some(existing) => merge(existing, value),
771                    None => {
772                        base.insert(key, value);
773                    }
774                }
775            }
776        }
777        (base, overlay) => *base = overlay,
778    }
779}
780
781fn validate_project_keys(value: &toml::Value) -> Result<()> {
782    let Some(table) = value.as_table() else {
783        bail!("project configuration must be a TOML table");
784    };
785    for forbidden in [
786        "provider",
787        "providers",
788        "provider_active",
789        "agents",
790        "update",
791    ] {
792        if table.contains_key(forbidden) {
793            bail!("project configuration cannot set [{forbidden}]");
794        }
795    }
796    if table
797        .get("skills")
798        .and_then(toml::Value::as_table)
799        .is_some_and(|skills| skills.contains_key("user_dir"))
800    {
801        bail!("project configuration cannot set skills.user_dir");
802    }
803    if table
804        .get("agent")
805        .and_then(toml::Value::as_table)
806        .is_some_and(|agent| agent.contains_key("system_prompt"))
807    {
808        bail!("project configuration cannot replace agent.system_prompt");
809    }
810    Ok(())
811}
812
813fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
814    macro_rules! no_larger {
815        ($field:expr, $name:literal) => {
816            if $field.1 > $field.0 {
817                bail!(concat!("project configuration cannot raise ", $name));
818            }
819        };
820    }
821    no_larger!(
822        (user.agent.max_steps, project.agent.max_steps),
823        "agent.max_steps"
824    );
825    no_larger!(
826        (
827            user.session.max_history_bytes,
828            project.session.max_history_bytes
829        ),
830        "session.max_history_bytes"
831    );
832    no_larger!(
833        (user.session.max_messages, project.session.max_messages),
834        "session.max_messages"
835    );
836    no_larger!(
837        (user.context.max_tokens, project.context.max_tokens),
838        "context.max_tokens"
839    );
840    no_larger!(
841        (
842            user.context.summary_max_chars,
843            project.context.summary_max_chars
844        ),
845        "context.summary_max_chars"
846    );
847    no_larger!(
848        (
849            user.tools.command_timeout_seconds,
850            project.tools.command_timeout_seconds
851        ),
852        "tools.command_timeout_seconds"
853    );
854    no_larger!(
855        (
856            user.tools.output_limit_bytes,
857            project.tools.output_limit_bytes
858        ),
859        "tools.output_limit_bytes"
860    );
861    no_larger!(
862        (user.tools.max_read_bytes, project.tools.max_read_bytes),
863        "tools.max_read_bytes"
864    );
865    no_larger!(
866        (user.tools.max_write_bytes, project.tools.max_write_bytes),
867        "tools.max_write_bytes"
868    );
869    no_larger!(
870        (
871            user.protocol.max_client_frame_bytes,
872            project.protocol.max_client_frame_bytes
873        ),
874        "protocol.max_client_frame_bytes"
875    );
876    no_larger!(
877        (
878            user.protocol.max_server_frame_bytes,
879            project.protocol.max_server_frame_bytes
880        ),
881        "protocol.max_server_frame_bytes"
882    );
883    no_larger!(
884        (
885            user.provider_limits.max_response_bytes,
886            project.provider_limits.max_response_bytes
887        ),
888        "provider_limits.max_response_bytes"
889    );
890    no_larger!(
891        (
892            user.provider_limits.max_sse_event_bytes,
893            project.provider_limits.max_sse_event_bytes
894        ),
895        "provider_limits.max_sse_event_bytes"
896    );
897    no_larger!(
898        (
899            user.provider_limits.max_assistant_bytes,
900            project.provider_limits.max_assistant_bytes
901        ),
902        "provider_limits.max_assistant_bytes"
903    );
904    no_larger!(
905        (
906            user.provider_limits.max_tool_calls,
907            project.provider_limits.max_tool_calls
908        ),
909        "provider_limits.max_tool_calls"
910    );
911    no_larger!(
912        (
913            user.provider_limits.max_tool_arguments_bytes,
914            project.provider_limits.max_tool_arguments_bytes
915        ),
916        "provider_limits.max_tool_arguments_bytes"
917    );
918    no_larger!(
919        (
920            user.tui.max_transcript_bytes,
921            project.tui.max_transcript_bytes
922        ),
923        "tui.max_transcript_bytes"
924    );
925    no_larger!(
926        (
927            user.tui.max_transcript_items,
928            project.tui.max_transcript_items
929        ),
930        "tui.max_transcript_items"
931    );
932    no_larger!(
933        (
934            user.tui.max_prompt_history_bytes,
935            project.tui.max_prompt_history_bytes
936        ),
937        "tui.max_prompt_history_bytes"
938    );
939    no_larger!(
940        (
941            user.tui.max_prompt_history_items,
942            project.tui.max_prompt_history_items
943        ),
944        "tui.max_prompt_history_items"
945    );
946    no_larger!(
947        (user.skills.max_skills, project.skills.max_skills),
948        "skills.max_skills"
949    );
950    no_larger!(
951        (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
952        "skills.max_skill_bytes"
953    );
954    if project.context.reserve_output_tokens < user.context.reserve_output_tokens
955        || project.context.safety_margin_tokens < user.context.safety_margin_tokens
956    {
957        bail!("project configuration cannot lower context reserves");
958    }
959    if project.context.bytes_per_token > user.context.bytes_per_token {
960        bail!("project configuration cannot raise context.bytes_per_token");
961    }
962    if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
963        bail!("project configuration cannot weaken tools.approval_policy");
964    }
965    Ok(())
966}
967
968fn expand_home(path: &std::path::Path) -> PathBuf {
969    let value = path.to_string_lossy();
970    if value == "~" {
971        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
972    }
973    if let Some(rest) = value.strip_prefix("~/")
974        && let Some(home) = dirs::home_dir()
975    {
976        return home.join(rest);
977    }
978    path.to_path_buf()
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984
985    #[test]
986    fn project_cannot_redirect_provider_or_agent() {
987        let provider: toml::Value = toml::from_str(
988            r#"[provider]
989base_url = "https://attacker.invalid"
990"#,
991        )
992        .unwrap();
993        assert!(validate_project_keys(&provider).is_err());
994
995        let agent: toml::Value = toml::from_str(
996            r#"[agents.codex]
997command = "/tmp/fake"
998"#,
999        )
1000        .unwrap();
1001        assert!(validate_project_keys(&agent).is_err());
1002    }
1003
1004    #[test]
1005    fn project_may_tighten_but_not_weaken_limits() {
1006        let user = Config::default();
1007        let mut tighter = user.clone();
1008        tighter.tools.output_limit_bytes /= 2;
1009        tighter.tools.approval_policy = ApprovalPolicy::Always;
1010        assert!(validate_project_not_weaker(&user, &tighter).is_ok());
1011
1012        let mut weaker = user.clone();
1013        weaker.tools.output_limit_bytes *= 2;
1014        assert!(validate_project_not_weaker(&user, &weaker).is_err());
1015    }
1016
1017    #[test]
1018    fn cross_field_validation_accounts_for_json_escaping() {
1019        let mut config = Config::default();
1020        config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
1021        assert!(config.validate().is_err());
1022    }
1023
1024    #[test]
1025    fn adapter_selection_templates_survive_partial_overrides_and_validate() {
1026        let mut value: toml::Value =
1027            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1028        merge(
1029            &mut value,
1030            toml::from_str(
1031                r#"[agents.claude]
1032args = ["-p", "--permission-mode", "acceptEdits"]
1033"#,
1034            )
1035            .unwrap(),
1036        );
1037        let config: Config = value.try_into().unwrap();
1038        assert_eq!(config.agents.claude.args.len(), 3);
1039        assert_eq!(config.agents.claude.model_args, ["--model", "{model}"]);
1040        assert_eq!(config.agents.claude.effort_args, ["--effort", "{effort}"]);
1041        assert!(config.agents.pi.model_args.is_empty());
1042
1043        let mut invalid = Config::default();
1044        invalid.agents.claude.effort_args = vec!["--effort".into()];
1045        assert!(
1046            invalid
1047                .validate()
1048                .unwrap_err()
1049                .to_string()
1050                .contains("agents.claude.effort_args must contain {effort}")
1051        );
1052    }
1053
1054    #[test]
1055    fn adapters_are_bound_to_the_instance_home() {
1056        let config = Config {
1057            instance_home: PathBuf::from("/tmp/scv-instance"),
1058            ..Config::default()
1059        };
1060        let adapters = config.adapters();
1061        let codex = &adapters["agent_codex"];
1062        assert!(codex.environment.contains(&(
1063            OsString::from("CODEX_HOME"),
1064            OsString::from("/tmp/scv-instance/adapters/codex")
1065        )));
1066        assert!(codex.environment.contains(&(
1067            OsString::from("SCV_HOME"),
1068            OsString::from("/tmp/scv-instance/adapters/codex")
1069        )));
1070    }
1071}