Skip to main content

wraith_runtime/
config.rs

1use std::collections::BTreeMap;
2use std::fmt::{Display, Formatter};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::json::JsonValue;
7use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
8
9pub const WRAITH_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum ConfigSource {
13    User,
14    Project,
15    Local,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ResolvedPermissionMode {
20    ReadOnly,
21    WorkspaceWrite,
22    DangerFullAccess,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ConfigEntry {
27    pub source: ConfigSource,
28    pub path: PathBuf,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct RuntimeConfig {
33    merged: BTreeMap<String, JsonValue>,
34    loaded_entries: Vec<ConfigEntry>,
35    feature_config: RuntimeFeatureConfig,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct RuntimePluginConfig {
40    enabled_plugins: BTreeMap<String, bool>,
41    external_directories: Vec<String>,
42    install_root: Option<String>,
43    registry_path: Option<String>,
44    bundled_root: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Default)]
48pub struct RuntimeFeatureConfig {
49    hooks: RuntimeHookConfig,
50    plugins: RuntimePluginConfig,
51    mcp: McpConfigCollection,
52    oauth: Option<OAuthConfig>,
53    model: Option<String>,
54    permission_mode: Option<ResolvedPermissionMode>,
55    sandbox: SandboxConfig,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Default)]
59pub struct RuntimeHookConfig {
60    pre_tool_use: Vec<String>,
61    post_tool_use: Vec<String>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Default)]
65pub struct McpConfigCollection {
66    servers: BTreeMap<String, ScopedMcpServerConfig>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ScopedMcpServerConfig {
71    pub scope: ConfigSource,
72    pub config: McpServerConfig,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum McpTransport {
77    Stdio,
78    Sse,
79    Http,
80    Ws,
81    Sdk,
82    ManagedProxy,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum McpServerConfig {
87    Stdio(McpStdioServerConfig),
88    Sse(McpRemoteServerConfig),
89    Http(McpRemoteServerConfig),
90    Ws(McpWebSocketServerConfig),
91    Sdk(McpSdkServerConfig),
92    ManagedProxy(McpManagedProxyServerConfig),
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct McpStdioServerConfig {
97    pub command: String,
98    pub args: Vec<String>,
99    pub env: BTreeMap<String, String>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct McpRemoteServerConfig {
104    pub url: String,
105    pub headers: BTreeMap<String, String>,
106    pub headers_helper: Option<String>,
107    pub oauth: Option<McpOAuthConfig>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct McpWebSocketServerConfig {
112    pub url: String,
113    pub headers: BTreeMap<String, String>,
114    pub headers_helper: Option<String>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct McpSdkServerConfig {
119    pub name: String,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct McpManagedProxyServerConfig {
124    pub url: String,
125    pub id: String,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct McpOAuthConfig {
130    pub client_id: Option<String>,
131    pub callback_port: Option<u16>,
132    pub auth_server_metadata_url: Option<String>,
133    pub xaa: Option<bool>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct OAuthConfig {
138    pub client_id: String,
139    pub authorize_url: String,
140    pub token_url: String,
141    pub callback_port: Option<u16>,
142    pub manual_redirect_url: Option<String>,
143    pub scopes: Vec<String>,
144}
145
146#[derive(Debug)]
147pub enum ConfigError {
148    Io(std::io::Error),
149    Parse(String),
150}
151
152impl Display for ConfigError {
153    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
154        match self {
155            Self::Io(error) => write!(f, "{error}"),
156            Self::Parse(error) => write!(f, "{error}"),
157        }
158    }
159}
160
161impl std::error::Error for ConfigError {}
162
163impl From<std::io::Error> for ConfigError {
164    fn from(value: std::io::Error) -> Self {
165        Self::Io(value)
166    }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct ConfigLoader {
171    cwd: PathBuf,
172    config_home: PathBuf,
173}
174
175impl ConfigLoader {
176    #[must_use]
177    pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
178        Self {
179            cwd: cwd.into(),
180            config_home: config_home.into(),
181        }
182    }
183
184    #[must_use]
185    pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
186        let cwd = cwd.into();
187        let config_home = default_config_home();
188        Self { cwd, config_home }
189    }
190
191    #[must_use]
192    pub fn config_home(&self) -> &Path {
193        &self.config_home
194    }
195
196    #[must_use]
197    pub fn discover(&self) -> Vec<ConfigEntry> {
198        let user_legacy_path = self.config_home.parent().map_or_else(
199            || PathBuf::from(".wraith.json"),
200            |parent| parent.join(".wraith.json"),
201        );
202        vec![
203            ConfigEntry {
204                source: ConfigSource::User,
205                path: user_legacy_path,
206            },
207            ConfigEntry {
208                source: ConfigSource::User,
209                path: self.config_home.join("settings.json"),
210            },
211            ConfigEntry {
212                source: ConfigSource::Project,
213                path: self.cwd.join(".wraith.json"),
214            },
215            ConfigEntry {
216                source: ConfigSource::Project,
217                path: self.cwd.join(".wraith").join("settings.json"),
218            },
219            ConfigEntry {
220                source: ConfigSource::Local,
221                path: self.cwd.join(".wraith").join("settings.local.json"),
222            },
223        ]
224    }
225
226    pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
227        let mut merged = BTreeMap::new();
228        let mut loaded_entries = Vec::new();
229        let mut mcp_servers = BTreeMap::new();
230
231        for entry in self.discover() {
232            let Some(value) = read_optional_json_object(&entry.path)? else {
233                continue;
234            };
235            merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
236            deep_merge_objects(&mut merged, &value);
237            loaded_entries.push(entry);
238        }
239
240        let merged_value = JsonValue::Object(merged.clone());
241
242        let feature_config = RuntimeFeatureConfig {
243            hooks: parse_optional_hooks_config(&merged_value)?,
244            plugins: parse_optional_plugin_config(&merged_value)?,
245            mcp: McpConfigCollection {
246                servers: mcp_servers,
247            },
248            oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
249            model: parse_optional_model(&merged_value),
250            permission_mode: parse_optional_permission_mode(&merged_value)?,
251            sandbox: parse_optional_sandbox_config(&merged_value)?,
252        };
253
254        Ok(RuntimeConfig {
255            merged,
256            loaded_entries,
257            feature_config,
258        })
259    }
260}
261
262impl RuntimeConfig {
263    #[must_use]
264    pub fn empty() -> Self {
265        Self {
266            merged: BTreeMap::new(),
267            loaded_entries: Vec::new(),
268            feature_config: RuntimeFeatureConfig::default(),
269        }
270    }
271
272    #[must_use]
273    pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
274        &self.merged
275    }
276
277    #[must_use]
278    pub fn loaded_entries(&self) -> &[ConfigEntry] {
279        &self.loaded_entries
280    }
281
282    #[must_use]
283    pub fn get(&self, key: &str) -> Option<&JsonValue> {
284        self.merged.get(key)
285    }
286
287    #[must_use]
288    pub fn as_json(&self) -> JsonValue {
289        JsonValue::Object(self.merged.clone())
290    }
291
292    #[must_use]
293    pub fn feature_config(&self) -> &RuntimeFeatureConfig {
294        &self.feature_config
295    }
296
297    #[must_use]
298    pub fn mcp(&self) -> &McpConfigCollection {
299        &self.feature_config.mcp
300    }
301
302    #[must_use]
303    pub fn hooks(&self) -> &RuntimeHookConfig {
304        &self.feature_config.hooks
305    }
306
307    #[must_use]
308    pub fn plugins(&self) -> &RuntimePluginConfig {
309        &self.feature_config.plugins
310    }
311
312    #[must_use]
313    pub fn oauth(&self) -> Option<&OAuthConfig> {
314        self.feature_config.oauth.as_ref()
315    }
316
317    #[must_use]
318    pub fn model(&self) -> Option<&str> {
319        self.feature_config.model.as_deref()
320    }
321
322    #[must_use]
323    pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
324        self.feature_config.permission_mode
325    }
326
327    #[must_use]
328    pub fn sandbox(&self) -> &SandboxConfig {
329        &self.feature_config.sandbox
330    }
331}
332
333impl RuntimeFeatureConfig {
334    #[must_use]
335    pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
336        self.hooks = hooks;
337        self
338    }
339
340    #[must_use]
341    pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self {
342        self.plugins = plugins;
343        self
344    }
345
346    #[must_use]
347    pub fn hooks(&self) -> &RuntimeHookConfig {
348        &self.hooks
349    }
350
351    #[must_use]
352    pub fn plugins(&self) -> &RuntimePluginConfig {
353        &self.plugins
354    }
355
356    #[must_use]
357    pub fn mcp(&self) -> &McpConfigCollection {
358        &self.mcp
359    }
360
361    #[must_use]
362    pub fn oauth(&self) -> Option<&OAuthConfig> {
363        self.oauth.as_ref()
364    }
365
366    #[must_use]
367    pub fn model(&self) -> Option<&str> {
368        self.model.as_deref()
369    }
370
371    #[must_use]
372    pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
373        self.permission_mode
374    }
375
376    #[must_use]
377    pub fn sandbox(&self) -> &SandboxConfig {
378        &self.sandbox
379    }
380}
381
382impl RuntimePluginConfig {
383    #[must_use]
384    pub fn enabled_plugins(&self) -> &BTreeMap<String, bool> {
385        &self.enabled_plugins
386    }
387
388    #[must_use]
389    pub fn external_directories(&self) -> &[String] {
390        &self.external_directories
391    }
392
393    #[must_use]
394    pub fn install_root(&self) -> Option<&str> {
395        self.install_root.as_deref()
396    }
397
398    #[must_use]
399    pub fn registry_path(&self) -> Option<&str> {
400        self.registry_path.as_deref()
401    }
402
403    #[must_use]
404    pub fn bundled_root(&self) -> Option<&str> {
405        self.bundled_root.as_deref()
406    }
407
408    pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) {
409        self.enabled_plugins.insert(plugin_id, enabled);
410    }
411
412    #[must_use]
413    pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool {
414        self.enabled_plugins
415            .get(plugin_id)
416            .copied()
417            .unwrap_or(default_enabled)
418    }
419}
420
421#[must_use]
422pub fn default_config_home() -> PathBuf {
423    std::env::var_os("WRAITH_CONFIG_HOME")
424        .map(PathBuf::from)
425        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".wraith")))
426        .unwrap_or_else(|| PathBuf::from(".wraith"))
427}
428
429impl RuntimeHookConfig {
430    #[must_use]
431    pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
432        Self {
433            pre_tool_use,
434            post_tool_use,
435        }
436    }
437
438    #[must_use]
439    pub fn pre_tool_use(&self) -> &[String] {
440        &self.pre_tool_use
441    }
442
443    #[must_use]
444    pub fn post_tool_use(&self) -> &[String] {
445        &self.post_tool_use
446    }
447
448    #[must_use]
449    pub fn merged(&self, other: &Self) -> Self {
450        let mut merged = self.clone();
451        merged.extend(other);
452        merged
453    }
454
455    pub fn extend(&mut self, other: &Self) {
456        extend_unique(&mut self.pre_tool_use, other.pre_tool_use());
457        extend_unique(&mut self.post_tool_use, other.post_tool_use());
458    }
459}
460
461impl McpConfigCollection {
462    #[must_use]
463    pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
464        &self.servers
465    }
466
467    #[must_use]
468    pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
469        self.servers.get(name)
470    }
471}
472
473impl ScopedMcpServerConfig {
474    #[must_use]
475    pub fn transport(&self) -> McpTransport {
476        self.config.transport()
477    }
478}
479
480impl McpServerConfig {
481    #[must_use]
482    pub fn transport(&self) -> McpTransport {
483        match self {
484            Self::Stdio(_) => McpTransport::Stdio,
485            Self::Sse(_) => McpTransport::Sse,
486            Self::Http(_) => McpTransport::Http,
487            Self::Ws(_) => McpTransport::Ws,
488            Self::Sdk(_) => McpTransport::Sdk,
489            Self::ManagedProxy(_) => McpTransport::ManagedProxy,
490        }
491    }
492}
493
494fn read_optional_json_object(
495    path: &Path,
496) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
497    let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".wraith.json");
498    let contents = match fs::read_to_string(path) {
499        Ok(contents) => contents,
500        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
501        Err(error) => return Err(ConfigError::Io(error)),
502    };
503
504    if contents.trim().is_empty() {
505        return Ok(Some(BTreeMap::new()));
506    }
507
508    let parsed = match JsonValue::parse(&contents) {
509        Ok(parsed) => parsed,
510        Err(_error) if is_legacy_config => return Ok(None),
511        Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
512    };
513    let Some(object) = parsed.as_object() else {
514        if is_legacy_config {
515            return Ok(None);
516        }
517        return Err(ConfigError::Parse(format!(
518            "{}: top-level settings value must be a JSON object",
519            path.display()
520        )));
521    };
522    Ok(Some(object.clone()))
523}
524
525fn merge_mcp_servers(
526    target: &mut BTreeMap<String, ScopedMcpServerConfig>,
527    source: ConfigSource,
528    root: &BTreeMap<String, JsonValue>,
529    path: &Path,
530) -> Result<(), ConfigError> {
531    let Some(mcp_servers) = root.get("mcpServers") else {
532        return Ok(());
533    };
534    let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
535    for (name, value) in servers {
536        let parsed = parse_mcp_server_config(
537            name,
538            value,
539            &format!("{}: mcpServers.{name}", path.display()),
540        )?;
541        target.insert(
542            name.clone(),
543            ScopedMcpServerConfig {
544                scope: source,
545                config: parsed,
546            },
547        );
548    }
549    Ok(())
550}
551
552fn parse_optional_model(root: &JsonValue) -> Option<String> {
553    root.as_object()
554        .and_then(|object| object.get("model"))
555        .and_then(JsonValue::as_str)
556        .map(ToOwned::to_owned)
557}
558
559fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
560    let Some(object) = root.as_object() else {
561        return Ok(RuntimeHookConfig::default());
562    };
563    let Some(hooks_value) = object.get("hooks") else {
564        return Ok(RuntimeHookConfig::default());
565    };
566    let hooks = expect_object(hooks_value, "merged settings.hooks")?;
567    Ok(RuntimeHookConfig {
568        pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
569            .unwrap_or_default(),
570        post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
571            .unwrap_or_default(),
572    })
573}
574
575fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
576    let Some(object) = root.as_object() else {
577        return Ok(RuntimePluginConfig::default());
578    };
579
580    let mut config = RuntimePluginConfig::default();
581    if let Some(enabled_plugins) = object.get("enabledPlugins") {
582        config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
583    }
584
585    let Some(plugins_value) = object.get("plugins") else {
586        return Ok(config);
587    };
588    let plugins = expect_object(plugins_value, "merged settings.plugins")?;
589
590    if let Some(enabled_value) = plugins.get("enabled") {
591        config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
592    }
593    config.external_directories =
594        optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
595            .unwrap_or_default();
596    config.install_root =
597        optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
598    config.registry_path =
599        optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
600    config.bundled_root =
601        optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
602    Ok(config)
603}
604
605fn parse_optional_permission_mode(
606    root: &JsonValue,
607) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
608    let Some(object) = root.as_object() else {
609        return Ok(None);
610    };
611    if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
612        return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
613    }
614    let Some(mode) = object
615        .get("permissions")
616        .and_then(JsonValue::as_object)
617        .and_then(|permissions| permissions.get("defaultMode"))
618        .and_then(JsonValue::as_str)
619    else {
620        return Ok(None);
621    };
622    parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
623}
624
625fn parse_permission_mode_label(
626    mode: &str,
627    context: &str,
628) -> Result<ResolvedPermissionMode, ConfigError> {
629    match mode {
630        "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
631        "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
632        "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
633        other => Err(ConfigError::Parse(format!(
634            "{context}: unsupported permission mode {other}"
635        ))),
636    }
637}
638
639fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
640    let Some(object) = root.as_object() else {
641        return Ok(SandboxConfig::default());
642    };
643    let Some(sandbox_value) = object.get("sandbox") else {
644        return Ok(SandboxConfig::default());
645    };
646    let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
647    let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
648        .map(parse_filesystem_mode_label)
649        .transpose()?;
650    Ok(SandboxConfig {
651        enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
652        namespace_restrictions: optional_bool(
653            sandbox,
654            "namespaceRestrictions",
655            "merged settings.sandbox",
656        )?,
657        network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
658        filesystem_mode,
659        allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
660            .unwrap_or_default(),
661    })
662}
663
664fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
665    match value {
666        "off" => Ok(FilesystemIsolationMode::Off),
667        "workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
668        "allow-list" => Ok(FilesystemIsolationMode::AllowList),
669        other => Err(ConfigError::Parse(format!(
670            "merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
671        ))),
672    }
673}
674
675fn parse_optional_oauth_config(
676    root: &JsonValue,
677    context: &str,
678) -> Result<Option<OAuthConfig>, ConfigError> {
679    let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
680        return Ok(None);
681    };
682    let object = expect_object(oauth_value, context)?;
683    let client_id = expect_string(object, "clientId", context)?.to_string();
684    let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
685    let token_url = expect_string(object, "tokenUrl", context)?.to_string();
686    let callback_port = optional_u16(object, "callbackPort", context)?;
687    let manual_redirect_url =
688        optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
689    let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
690    Ok(Some(OAuthConfig {
691        client_id,
692        authorize_url,
693        token_url,
694        callback_port,
695        manual_redirect_url,
696        scopes,
697    }))
698}
699
700fn parse_mcp_server_config(
701    server_name: &str,
702    value: &JsonValue,
703    context: &str,
704) -> Result<McpServerConfig, ConfigError> {
705    let object = expect_object(value, context)?;
706    let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
707    match server_type {
708        "stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
709            command: expect_string(object, "command", context)?.to_string(),
710            args: optional_string_array(object, "args", context)?.unwrap_or_default(),
711            env: optional_string_map(object, "env", context)?.unwrap_or_default(),
712        })),
713        "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
714            object, context,
715        )?)),
716        "http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
717            object, context,
718        )?)),
719        "ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
720            url: expect_string(object, "url", context)?.to_string(),
721            headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
722            headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
723        })),
724        "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
725            name: expect_string(object, "name", context)?.to_string(),
726        })),
727        "claudeai-proxy" => Ok(McpServerConfig::ManagedProxy(McpManagedProxyServerConfig {
728            url: expect_string(object, "url", context)?.to_string(),
729            id: expect_string(object, "id", context)?.to_string(),
730        })),
731        other => Err(ConfigError::Parse(format!(
732            "{context}: unsupported MCP server type for {server_name}: {other}"
733        ))),
734    }
735}
736
737fn parse_mcp_remote_server_config(
738    object: &BTreeMap<String, JsonValue>,
739    context: &str,
740) -> Result<McpRemoteServerConfig, ConfigError> {
741    Ok(McpRemoteServerConfig {
742        url: expect_string(object, "url", context)?.to_string(),
743        headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
744        headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
745        oauth: parse_optional_mcp_oauth_config(object, context)?,
746    })
747}
748
749fn parse_optional_mcp_oauth_config(
750    object: &BTreeMap<String, JsonValue>,
751    context: &str,
752) -> Result<Option<McpOAuthConfig>, ConfigError> {
753    let Some(value) = object.get("oauth") else {
754        return Ok(None);
755    };
756    let oauth = expect_object(value, &format!("{context}.oauth"))?;
757    Ok(Some(McpOAuthConfig {
758        client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
759        callback_port: optional_u16(oauth, "callbackPort", context)?,
760        auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
761            .map(str::to_string),
762        xaa: optional_bool(oauth, "xaa", context)?,
763    }))
764}
765
766fn expect_object<'a>(
767    value: &'a JsonValue,
768    context: &str,
769) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
770    value
771        .as_object()
772        .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
773}
774
775fn expect_string<'a>(
776    object: &'a BTreeMap<String, JsonValue>,
777    key: &str,
778    context: &str,
779) -> Result<&'a str, ConfigError> {
780    object
781        .get(key)
782        .and_then(JsonValue::as_str)
783        .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
784}
785
786fn optional_string<'a>(
787    object: &'a BTreeMap<String, JsonValue>,
788    key: &str,
789    context: &str,
790) -> Result<Option<&'a str>, ConfigError> {
791    match object.get(key) {
792        Some(value) => value
793            .as_str()
794            .map(Some)
795            .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
796        None => Ok(None),
797    }
798}
799
800fn optional_bool(
801    object: &BTreeMap<String, JsonValue>,
802    key: &str,
803    context: &str,
804) -> Result<Option<bool>, ConfigError> {
805    match object.get(key) {
806        Some(value) => value
807            .as_bool()
808            .map(Some)
809            .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
810        None => Ok(None),
811    }
812}
813
814fn optional_u16(
815    object: &BTreeMap<String, JsonValue>,
816    key: &str,
817    context: &str,
818) -> Result<Option<u16>, ConfigError> {
819    match object.get(key) {
820        Some(value) => {
821            let Some(number) = value.as_i64() else {
822                return Err(ConfigError::Parse(format!(
823                    "{context}: field {key} must be an integer"
824                )));
825            };
826            let number = u16::try_from(number).map_err(|_| {
827                ConfigError::Parse(format!("{context}: field {key} is out of range"))
828            })?;
829            Ok(Some(number))
830        }
831        None => Ok(None),
832    }
833}
834
835fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
836    let Some(map) = value.as_object() else {
837        return Err(ConfigError::Parse(format!(
838            "{context}: expected JSON object"
839        )));
840    };
841    map.iter()
842        .map(|(key, value)| {
843            value
844                .as_bool()
845                .map(|enabled| (key.clone(), enabled))
846                .ok_or_else(|| {
847                    ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
848                })
849        })
850        .collect()
851}
852
853fn optional_string_array(
854    object: &BTreeMap<String, JsonValue>,
855    key: &str,
856    context: &str,
857) -> Result<Option<Vec<String>>, ConfigError> {
858    match object.get(key) {
859        Some(value) => {
860            let Some(array) = value.as_array() else {
861                return Err(ConfigError::Parse(format!(
862                    "{context}: field {key} must be an array"
863                )));
864            };
865            array
866                .iter()
867                .map(|item| {
868                    item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
869                        ConfigError::Parse(format!(
870                            "{context}: field {key} must contain only strings"
871                        ))
872                    })
873                })
874                .collect::<Result<Vec<_>, _>>()
875                .map(Some)
876        }
877        None => Ok(None),
878    }
879}
880
881fn optional_string_map(
882    object: &BTreeMap<String, JsonValue>,
883    key: &str,
884    context: &str,
885) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
886    match object.get(key) {
887        Some(value) => {
888            let Some(map) = value.as_object() else {
889                return Err(ConfigError::Parse(format!(
890                    "{context}: field {key} must be an object"
891                )));
892            };
893            map.iter()
894                .map(|(entry_key, entry_value)| {
895                    entry_value
896                        .as_str()
897                        .map(|text| (entry_key.clone(), text.to_string()))
898                        .ok_or_else(|| {
899                            ConfigError::Parse(format!(
900                                "{context}: field {key} must contain only string values"
901                            ))
902                        })
903                })
904                .collect::<Result<BTreeMap<_, _>, _>>()
905                .map(Some)
906        }
907        None => Ok(None),
908    }
909}
910
911fn deep_merge_objects(
912    target: &mut BTreeMap<String, JsonValue>,
913    source: &BTreeMap<String, JsonValue>,
914) {
915    for (key, value) in source {
916        match (target.get_mut(key), value) {
917            (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
918                deep_merge_objects(existing, incoming);
919            }
920            _ => {
921                target.insert(key.clone(), value.clone());
922            }
923        }
924    }
925}
926
927fn extend_unique(target: &mut Vec<String>, values: &[String]) {
928    for value in values {
929        push_unique(target, value.clone());
930    }
931}
932
933fn push_unique(target: &mut Vec<String>, value: String) {
934    if !target.iter().any(|existing| existing == &value) {
935        target.push(value);
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    use super::{
942        ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
943        WRAITH_SETTINGS_SCHEMA_NAME,
944    };
945    use crate::json::JsonValue;
946    use crate::sandbox::FilesystemIsolationMode;
947    use std::fs;
948    use std::time::{SystemTime, UNIX_EPOCH};
949
950    fn temp_dir() -> std::path::PathBuf {
951        let nanos = SystemTime::now()
952            .duration_since(UNIX_EPOCH)
953            .expect("time should be after epoch")
954            .as_nanos();
955        std::env::temp_dir().join(format!("runtime-config-{nanos}"))
956    }
957
958    #[test]
959    fn rejects_non_object_settings_files() {
960        let root = temp_dir();
961        let cwd = root.join("project");
962        let home = root.join("home").join(".wraith");
963        fs::create_dir_all(&home).expect("home config dir");
964        fs::create_dir_all(&cwd).expect("project dir");
965        fs::write(home.join("settings.json"), "[]").expect("write bad settings");
966
967        let error = ConfigLoader::new(&cwd, &home)
968            .load()
969            .expect_err("config should fail");
970        assert!(error
971            .to_string()
972            .contains("top-level settings value must be a JSON object"));
973
974        fs::remove_dir_all(root).expect("cleanup temp dir");
975    }
976
977    #[test]
978    fn loads_and_merges_wraith_config_files_by_precedence() {
979        let root = temp_dir();
980        let cwd = root.join("project");
981        let home = root.join("home").join(".wraith");
982        fs::create_dir_all(cwd.join(".wraith")).expect("project config dir");
983        fs::create_dir_all(&home).expect("home config dir");
984
985        fs::write(
986            home.parent().expect("home parent").join(".wraith.json"),
987            r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
988        )
989        .expect("write user compat config");
990        fs::write(
991            home.join("settings.json"),
992            r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
993        )
994        .expect("write user settings");
995        fs::write(
996            cwd.join(".wraith.json"),
997            r#"{"model":"project-compat","env":{"B":"2"}}"#,
998        )
999        .expect("write project compat config");
1000        fs::write(
1001            cwd.join(".wraith").join("settings.json"),
1002            r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
1003        )
1004        .expect("write project settings");
1005        fs::write(
1006            cwd.join(".wraith").join("settings.local.json"),
1007            r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
1008        )
1009        .expect("write local settings");
1010
1011        let loaded = ConfigLoader::new(&cwd, &home)
1012            .load()
1013            .expect("config should load");
1014
1015        assert_eq!(WRAITH_SETTINGS_SCHEMA_NAME, "SettingsSchema");
1016        assert_eq!(loaded.loaded_entries().len(), 5);
1017        assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
1018        assert_eq!(
1019            loaded.get("model"),
1020            Some(&JsonValue::String("opus".to_string()))
1021        );
1022        assert_eq!(loaded.model(), Some("opus"));
1023        assert_eq!(
1024            loaded.permission_mode(),
1025            Some(ResolvedPermissionMode::WorkspaceWrite)
1026        );
1027        assert_eq!(
1028            loaded
1029                .get("env")
1030                .and_then(JsonValue::as_object)
1031                .expect("env object")
1032                .len(),
1033            4
1034        );
1035        assert!(loaded
1036            .get("hooks")
1037            .and_then(JsonValue::as_object)
1038            .expect("hooks object")
1039            .contains_key("PreToolUse"));
1040        assert!(loaded
1041            .get("hooks")
1042            .and_then(JsonValue::as_object)
1043            .expect("hooks object")
1044            .contains_key("PostToolUse"));
1045        assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
1046        assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
1047        assert!(loaded.mcp().get("home").is_some());
1048        assert!(loaded.mcp().get("project").is_some());
1049
1050        fs::remove_dir_all(root).expect("cleanup temp dir");
1051    }
1052
1053    #[test]
1054    fn parses_sandbox_config() {
1055        let root = temp_dir();
1056        let cwd = root.join("project");
1057        let home = root.join("home").join(".wraith");
1058        fs::create_dir_all(cwd.join(".wraith")).expect("project config dir");
1059        fs::create_dir_all(&home).expect("home config dir");
1060
1061        fs::write(
1062            cwd.join(".wraith").join("settings.local.json"),
1063            r#"{
1064              "sandbox": {
1065                "enabled": true,
1066                "namespaceRestrictions": false,
1067                "networkIsolation": true,
1068                "filesystemMode": "allow-list",
1069                "allowedMounts": ["logs", "tmp/cache"]
1070              }
1071            }"#,
1072        )
1073        .expect("write local settings");
1074
1075        let loaded = ConfigLoader::new(&cwd, &home)
1076            .load()
1077            .expect("config should load");
1078
1079        assert_eq!(loaded.sandbox().enabled, Some(true));
1080        assert_eq!(loaded.sandbox().namespace_restrictions, Some(false));
1081        assert_eq!(loaded.sandbox().network_isolation, Some(true));
1082        assert_eq!(
1083            loaded.sandbox().filesystem_mode,
1084            Some(FilesystemIsolationMode::AllowList)
1085        );
1086        assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]);
1087
1088        fs::remove_dir_all(root).expect("cleanup temp dir");
1089    }
1090
1091    #[test]
1092    fn parses_typed_mcp_and_oauth_config() {
1093        let root = temp_dir();
1094        let cwd = root.join("project");
1095        let home = root.join("home").join(".wraith");
1096        fs::create_dir_all(cwd.join(".wraith")).expect("project config dir");
1097        fs::create_dir_all(&home).expect("home config dir");
1098
1099        fs::write(
1100            home.join("settings.json"),
1101            r#"{
1102              "mcpServers": {
1103                "stdio-server": {
1104                  "command": "uvx",
1105                  "args": ["mcp-server"],
1106                  "env": {"TOKEN": "secret"}
1107                },
1108                "remote-server": {
1109                  "type": "http",
1110                  "url": "https://example.test/mcp",
1111                  "headers": {"Authorization": "Bearer token"},
1112                  "headersHelper": "helper.sh",
1113                  "oauth": {
1114                    "clientId": "mcp-client",
1115                    "callbackPort": 7777,
1116                    "authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
1117                    "xaa": true
1118                  }
1119                }
1120              },
1121              "oauth": {
1122                "clientId": "runtime-client",
1123                "authorizeUrl": "https://console.test/oauth/authorize",
1124                "tokenUrl": "https://console.test/oauth/token",
1125                "callbackPort": 54545,
1126                "manualRedirectUrl": "https://console.test/oauth/callback",
1127                "scopes": ["org:read", "user:write"]
1128              }
1129            }"#,
1130        )
1131        .expect("write user settings");
1132        fs::write(
1133            cwd.join(".wraith").join("settings.local.json"),
1134            r#"{
1135              "mcpServers": {
1136                "remote-server": {
1137                  "type": "ws",
1138                  "url": "wss://override.test/mcp",
1139                  "headers": {"X-Env": "local"}
1140                }
1141              }
1142            }"#,
1143        )
1144        .expect("write local settings");
1145
1146        let loaded = ConfigLoader::new(&cwd, &home)
1147            .load()
1148            .expect("config should load");
1149
1150        let stdio_server = loaded
1151            .mcp()
1152            .get("stdio-server")
1153            .expect("stdio server should exist");
1154        assert_eq!(stdio_server.scope, ConfigSource::User);
1155        assert_eq!(stdio_server.transport(), McpTransport::Stdio);
1156
1157        let remote_server = loaded
1158            .mcp()
1159            .get("remote-server")
1160            .expect("remote server should exist");
1161        assert_eq!(remote_server.scope, ConfigSource::Local);
1162        assert_eq!(remote_server.transport(), McpTransport::Ws);
1163        match &remote_server.config {
1164            McpServerConfig::Ws(config) => {
1165                assert_eq!(config.url, "wss://override.test/mcp");
1166                assert_eq!(
1167                    config.headers.get("X-Env").map(String::as_str),
1168                    Some("local")
1169                );
1170            }
1171            other => panic!("expected ws config, got {other:?}"),
1172        }
1173
1174        let oauth = loaded.oauth().expect("oauth config should exist");
1175        assert_eq!(oauth.client_id, "runtime-client");
1176        assert_eq!(oauth.callback_port, Some(54_545));
1177        assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
1178
1179        fs::remove_dir_all(root).expect("cleanup temp dir");
1180    }
1181
1182    #[test]
1183    fn parses_plugin_config_from_enabled_plugins() {
1184        let root = temp_dir();
1185        let cwd = root.join("project");
1186        let home = root.join("home").join(".wraith");
1187        fs::create_dir_all(cwd.join(".wraith")).expect("project config dir");
1188        fs::create_dir_all(&home).expect("home config dir");
1189
1190        fs::write(
1191            home.join("settings.json"),
1192            r#"{
1193              "enabledPlugins": {
1194                "tool-guard@builtin": true,
1195                "sample-plugin@external": false
1196              }
1197            }"#,
1198        )
1199        .expect("write user settings");
1200
1201        let loaded = ConfigLoader::new(&cwd, &home)
1202            .load()
1203            .expect("config should load");
1204
1205        assert_eq!(
1206            loaded.plugins().enabled_plugins().get("tool-guard@builtin"),
1207            Some(&true)
1208        );
1209        assert_eq!(
1210            loaded
1211                .plugins()
1212                .enabled_plugins()
1213                .get("sample-plugin@external"),
1214            Some(&false)
1215        );
1216
1217        fs::remove_dir_all(root).expect("cleanup temp dir");
1218    }
1219
1220    #[test]
1221    fn parses_plugin_config() {
1222        let root = temp_dir();
1223        let cwd = root.join("project");
1224        let home = root.join("home").join(".wraith");
1225        fs::create_dir_all(cwd.join(".wraith")).expect("project config dir");
1226        fs::create_dir_all(&home).expect("home config dir");
1227
1228        fs::write(
1229            home.join("settings.json"),
1230            r#"{
1231              "enabledPlugins": {
1232                "core-helpers@builtin": true
1233              },
1234              "plugins": {
1235                "externalDirectories": ["./external-plugins"],
1236                "installRoot": "plugin-cache/installed",
1237                "registryPath": "plugin-cache/installed.json",
1238                "bundledRoot": "./bundled-plugins"
1239              }
1240            }"#,
1241        )
1242        .expect("write plugin settings");
1243
1244        let loaded = ConfigLoader::new(&cwd, &home)
1245            .load()
1246            .expect("config should load");
1247
1248        assert_eq!(
1249            loaded
1250                .plugins()
1251                .enabled_plugins()
1252                .get("core-helpers@builtin"),
1253            Some(&true)
1254        );
1255        assert_eq!(
1256            loaded.plugins().external_directories(),
1257            &["./external-plugins".to_string()]
1258        );
1259        assert_eq!(
1260            loaded.plugins().install_root(),
1261            Some("plugin-cache/installed")
1262        );
1263        assert_eq!(
1264            loaded.plugins().registry_path(),
1265            Some("plugin-cache/installed.json")
1266        );
1267        assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
1268
1269        fs::remove_dir_all(root).expect("cleanup temp dir");
1270    }
1271
1272    #[test]
1273    fn rejects_invalid_mcp_server_shapes() {
1274        let root = temp_dir();
1275        let cwd = root.join("project");
1276        let home = root.join("home").join(".wraith");
1277        fs::create_dir_all(&home).expect("home config dir");
1278        fs::create_dir_all(&cwd).expect("project dir");
1279        fs::write(
1280            home.join("settings.json"),
1281            r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
1282        )
1283        .expect("write broken settings");
1284
1285        let error = ConfigLoader::new(&cwd, &home)
1286            .load()
1287            .expect_err("config should fail");
1288        assert!(error
1289            .to_string()
1290            .contains("mcpServers.broken: missing string field url"));
1291
1292        fs::remove_dir_all(root).expect("cleanup temp dir");
1293    }
1294}