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}