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