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