997 lines
31 KiB
Rust
997 lines
31 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::fmt::{Display, Formatter};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::json::JsonValue;
|
|
use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
|
|
|
|
pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum ConfigSource {
|
|
User,
|
|
Project,
|
|
Local,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ResolvedPermissionMode {
|
|
ReadOnly,
|
|
WorkspaceWrite,
|
|
DangerFullAccess,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ConfigEntry {
|
|
pub source: ConfigSource,
|
|
pub path: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RuntimeConfig {
|
|
merged: BTreeMap<String, JsonValue>,
|
|
loaded_entries: Vec<ConfigEntry>,
|
|
feature_config: RuntimeFeatureConfig,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct RuntimeFeatureConfig {
|
|
mcp: McpConfigCollection,
|
|
oauth: Option<OAuthConfig>,
|
|
model: Option<String>,
|
|
permission_mode: Option<ResolvedPermissionMode>,
|
|
sandbox: SandboxConfig,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct McpConfigCollection {
|
|
servers: BTreeMap<String, ScopedMcpServerConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ScopedMcpServerConfig {
|
|
pub scope: ConfigSource,
|
|
pub config: McpServerConfig,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum McpTransport {
|
|
Stdio,
|
|
Sse,
|
|
Http,
|
|
Ws,
|
|
Sdk,
|
|
ClaudeAiProxy,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum McpServerConfig {
|
|
Stdio(McpStdioServerConfig),
|
|
Sse(McpRemoteServerConfig),
|
|
Http(McpRemoteServerConfig),
|
|
Ws(McpWebSocketServerConfig),
|
|
Sdk(McpSdkServerConfig),
|
|
ClaudeAiProxy(McpClaudeAiProxyServerConfig),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpStdioServerConfig {
|
|
pub command: String,
|
|
pub args: Vec<String>,
|
|
pub env: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpRemoteServerConfig {
|
|
pub url: String,
|
|
pub headers: BTreeMap<String, String>,
|
|
pub headers_helper: Option<String>,
|
|
pub oauth: Option<McpOAuthConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpWebSocketServerConfig {
|
|
pub url: String,
|
|
pub headers: BTreeMap<String, String>,
|
|
pub headers_helper: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpSdkServerConfig {
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpClaudeAiProxyServerConfig {
|
|
pub url: String,
|
|
pub id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpOAuthConfig {
|
|
pub client_id: Option<String>,
|
|
pub callback_port: Option<u16>,
|
|
pub auth_server_metadata_url: Option<String>,
|
|
pub xaa: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct OAuthConfig {
|
|
pub client_id: String,
|
|
pub authorize_url: String,
|
|
pub token_url: String,
|
|
pub callback_port: Option<u16>,
|
|
pub manual_redirect_url: Option<String>,
|
|
pub scopes: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ConfigError {
|
|
Io(std::io::Error),
|
|
Parse(String),
|
|
}
|
|
|
|
impl Display for ConfigError {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Io(error) => write!(f, "{error}"),
|
|
Self::Parse(error) => write!(f, "{error}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ConfigError {}
|
|
|
|
impl From<std::io::Error> for ConfigError {
|
|
fn from(value: std::io::Error) -> Self {
|
|
Self::Io(value)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ConfigLoader {
|
|
cwd: PathBuf,
|
|
config_home: PathBuf,
|
|
}
|
|
|
|
impl ConfigLoader {
|
|
#[must_use]
|
|
pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
|
|
Self {
|
|
cwd: cwd.into(),
|
|
config_home: config_home.into(),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
|
|
let cwd = cwd.into();
|
|
let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
|
|
.map(PathBuf::from)
|
|
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
|
|
.unwrap_or_else(|| PathBuf::from(".claude"));
|
|
Self { cwd, config_home }
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn discover(&self) -> Vec<ConfigEntry> {
|
|
let user_legacy_path = self.config_home.parent().map_or_else(
|
|
|| PathBuf::from(".claude.json"),
|
|
|parent| parent.join(".claude.json"),
|
|
);
|
|
vec![
|
|
ConfigEntry {
|
|
source: ConfigSource::User,
|
|
path: user_legacy_path,
|
|
},
|
|
ConfigEntry {
|
|
source: ConfigSource::User,
|
|
path: self.config_home.join("settings.json"),
|
|
},
|
|
ConfigEntry {
|
|
source: ConfigSource::Project,
|
|
path: self.cwd.join(".claude.json"),
|
|
},
|
|
ConfigEntry {
|
|
source: ConfigSource::Project,
|
|
path: self.cwd.join(".claude").join("settings.json"),
|
|
},
|
|
ConfigEntry {
|
|
source: ConfigSource::Local,
|
|
path: self.cwd.join(".claude").join("settings.local.json"),
|
|
},
|
|
]
|
|
}
|
|
|
|
pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
|
|
let mut merged = BTreeMap::new();
|
|
let mut loaded_entries = Vec::new();
|
|
let mut mcp_servers = BTreeMap::new();
|
|
|
|
for entry in self.discover() {
|
|
let Some(value) = read_optional_json_object(&entry.path)? else {
|
|
continue;
|
|
};
|
|
merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
|
|
deep_merge_objects(&mut merged, &value);
|
|
loaded_entries.push(entry);
|
|
}
|
|
|
|
let merged_value = JsonValue::Object(merged.clone());
|
|
|
|
let feature_config = RuntimeFeatureConfig {
|
|
mcp: McpConfigCollection {
|
|
servers: mcp_servers,
|
|
},
|
|
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
|
|
model: parse_optional_model(&merged_value),
|
|
permission_mode: parse_optional_permission_mode(&merged_value)?,
|
|
sandbox: parse_optional_sandbox_config(&merged_value)?,
|
|
};
|
|
|
|
Ok(RuntimeConfig {
|
|
merged,
|
|
loaded_entries,
|
|
feature_config,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl RuntimeConfig {
|
|
#[must_use]
|
|
pub fn empty() -> Self {
|
|
Self {
|
|
merged: BTreeMap::new(),
|
|
loaded_entries: Vec::new(),
|
|
feature_config: RuntimeFeatureConfig::default(),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
|
|
&self.merged
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn loaded_entries(&self) -> &[ConfigEntry] {
|
|
&self.loaded_entries
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get(&self, key: &str) -> Option<&JsonValue> {
|
|
self.merged.get(key)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn as_json(&self) -> JsonValue {
|
|
JsonValue::Object(self.merged.clone())
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn feature_config(&self) -> &RuntimeFeatureConfig {
|
|
&self.feature_config
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn mcp(&self) -> &McpConfigCollection {
|
|
&self.feature_config.mcp
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
|
self.feature_config.oauth.as_ref()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn model(&self) -> Option<&str> {
|
|
self.feature_config.model.as_deref()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
|
|
self.feature_config.permission_mode
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn sandbox(&self) -> &SandboxConfig {
|
|
&self.feature_config.sandbox
|
|
}
|
|
}
|
|
|
|
impl RuntimeFeatureConfig {
|
|
#[must_use]
|
|
pub fn mcp(&self) -> &McpConfigCollection {
|
|
&self.mcp
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
|
self.oauth.as_ref()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn model(&self) -> Option<&str> {
|
|
self.model.as_deref()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
|
|
self.permission_mode
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn sandbox(&self) -> &SandboxConfig {
|
|
&self.sandbox
|
|
}
|
|
}
|
|
|
|
impl McpConfigCollection {
|
|
#[must_use]
|
|
pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
|
|
&self.servers
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
|
|
self.servers.get(name)
|
|
}
|
|
}
|
|
|
|
impl ScopedMcpServerConfig {
|
|
#[must_use]
|
|
pub fn transport(&self) -> McpTransport {
|
|
self.config.transport()
|
|
}
|
|
}
|
|
|
|
impl McpServerConfig {
|
|
#[must_use]
|
|
pub fn transport(&self) -> McpTransport {
|
|
match self {
|
|
Self::Stdio(_) => McpTransport::Stdio,
|
|
Self::Sse(_) => McpTransport::Sse,
|
|
Self::Http(_) => McpTransport::Http,
|
|
Self::Ws(_) => McpTransport::Ws,
|
|
Self::Sdk(_) => McpTransport::Sdk,
|
|
Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_optional_json_object(
|
|
path: &Path,
|
|
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
|
|
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json");
|
|
let contents = match fs::read_to_string(path) {
|
|
Ok(contents) => contents,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(error) => return Err(ConfigError::Io(error)),
|
|
};
|
|
|
|
if contents.trim().is_empty() {
|
|
return Ok(Some(BTreeMap::new()));
|
|
}
|
|
|
|
let parsed = match JsonValue::parse(&contents) {
|
|
Ok(parsed) => parsed,
|
|
Err(error) if is_legacy_config => return Ok(None),
|
|
Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
|
|
};
|
|
let Some(object) = parsed.as_object() else {
|
|
if is_legacy_config {
|
|
return Ok(None);
|
|
}
|
|
return Err(ConfigError::Parse(format!(
|
|
"{}: top-level settings value must be a JSON object",
|
|
path.display()
|
|
)));
|
|
};
|
|
Ok(Some(object.clone()))
|
|
}
|
|
|
|
fn merge_mcp_servers(
|
|
target: &mut BTreeMap<String, ScopedMcpServerConfig>,
|
|
source: ConfigSource,
|
|
root: &BTreeMap<String, JsonValue>,
|
|
path: &Path,
|
|
) -> Result<(), ConfigError> {
|
|
let Some(mcp_servers) = root.get("mcpServers") else {
|
|
return Ok(());
|
|
};
|
|
let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
|
|
for (name, value) in servers {
|
|
let parsed = parse_mcp_server_config(
|
|
name,
|
|
value,
|
|
&format!("{}: mcpServers.{name}", path.display()),
|
|
)?;
|
|
target.insert(
|
|
name.clone(),
|
|
ScopedMcpServerConfig {
|
|
scope: source,
|
|
config: parsed,
|
|
},
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
|
root.as_object()
|
|
.and_then(|object| object.get("model"))
|
|
.and_then(JsonValue::as_str)
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn parse_optional_permission_mode(
|
|
root: &JsonValue,
|
|
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
|
|
let Some(object) = root.as_object() else {
|
|
return Ok(None);
|
|
};
|
|
if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
|
|
return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
|
|
}
|
|
let Some(mode) = object
|
|
.get("permissions")
|
|
.and_then(JsonValue::as_object)
|
|
.and_then(|permissions| permissions.get("defaultMode"))
|
|
.and_then(JsonValue::as_str)
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
|
|
}
|
|
|
|
fn parse_permission_mode_label(
|
|
mode: &str,
|
|
context: &str,
|
|
) -> Result<ResolvedPermissionMode, ConfigError> {
|
|
match mode {
|
|
"default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
|
|
"acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
|
|
"dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
|
|
other => Err(ConfigError::Parse(format!(
|
|
"{context}: unsupported permission mode {other}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
|
|
let Some(object) = root.as_object() else {
|
|
return Ok(SandboxConfig::default());
|
|
};
|
|
let Some(sandbox_value) = object.get("sandbox") else {
|
|
return Ok(SandboxConfig::default());
|
|
};
|
|
let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
|
|
let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
|
|
.map(parse_filesystem_mode_label)
|
|
.transpose()?;
|
|
Ok(SandboxConfig {
|
|
enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
|
|
namespace_restrictions: optional_bool(
|
|
sandbox,
|
|
"namespaceRestrictions",
|
|
"merged settings.sandbox",
|
|
)?,
|
|
network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
|
|
filesystem_mode,
|
|
allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
|
|
.unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
|
|
match value {
|
|
"off" => Ok(FilesystemIsolationMode::Off),
|
|
"workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
|
|
"allow-list" => Ok(FilesystemIsolationMode::AllowList),
|
|
other => Err(ConfigError::Parse(format!(
|
|
"merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_optional_oauth_config(
|
|
root: &JsonValue,
|
|
context: &str,
|
|
) -> Result<Option<OAuthConfig>, ConfigError> {
|
|
let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
|
|
return Ok(None);
|
|
};
|
|
let object = expect_object(oauth_value, context)?;
|
|
let client_id = expect_string(object, "clientId", context)?.to_string();
|
|
let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
|
|
let token_url = expect_string(object, "tokenUrl", context)?.to_string();
|
|
let callback_port = optional_u16(object, "callbackPort", context)?;
|
|
let manual_redirect_url =
|
|
optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
|
|
let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
|
|
Ok(Some(OAuthConfig {
|
|
client_id,
|
|
authorize_url,
|
|
token_url,
|
|
callback_port,
|
|
manual_redirect_url,
|
|
scopes,
|
|
}))
|
|
}
|
|
|
|
fn parse_mcp_server_config(
|
|
server_name: &str,
|
|
value: &JsonValue,
|
|
context: &str,
|
|
) -> Result<McpServerConfig, ConfigError> {
|
|
let object = expect_object(value, context)?;
|
|
let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
|
|
match server_type {
|
|
"stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
|
|
command: expect_string(object, "command", context)?.to_string(),
|
|
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
|
|
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
|
|
})),
|
|
"sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
|
|
object, context,
|
|
)?)),
|
|
"http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
|
|
object, context,
|
|
)?)),
|
|
"ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
|
|
url: expect_string(object, "url", context)?.to_string(),
|
|
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
|
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
|
})),
|
|
"sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
|
|
name: expect_string(object, "name", context)?.to_string(),
|
|
})),
|
|
"claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy(
|
|
McpClaudeAiProxyServerConfig {
|
|
url: expect_string(object, "url", context)?.to_string(),
|
|
id: expect_string(object, "id", context)?.to_string(),
|
|
},
|
|
)),
|
|
other => Err(ConfigError::Parse(format!(
|
|
"{context}: unsupported MCP server type for {server_name}: {other}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_mcp_remote_server_config(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
context: &str,
|
|
) -> Result<McpRemoteServerConfig, ConfigError> {
|
|
Ok(McpRemoteServerConfig {
|
|
url: expect_string(object, "url", context)?.to_string(),
|
|
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
|
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
|
oauth: parse_optional_mcp_oauth_config(object, context)?,
|
|
})
|
|
}
|
|
|
|
fn parse_optional_mcp_oauth_config(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
context: &str,
|
|
) -> Result<Option<McpOAuthConfig>, ConfigError> {
|
|
let Some(value) = object.get("oauth") else {
|
|
return Ok(None);
|
|
};
|
|
let oauth = expect_object(value, &format!("{context}.oauth"))?;
|
|
Ok(Some(McpOAuthConfig {
|
|
client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
|
|
callback_port: optional_u16(oauth, "callbackPort", context)?,
|
|
auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
|
|
.map(str::to_string),
|
|
xaa: optional_bool(oauth, "xaa", context)?,
|
|
}))
|
|
}
|
|
|
|
fn expect_object<'a>(
|
|
value: &'a JsonValue,
|
|
context: &str,
|
|
) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
|
|
value
|
|
.as_object()
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
|
|
}
|
|
|
|
fn expect_string<'a>(
|
|
object: &'a BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<&'a str, ConfigError> {
|
|
object
|
|
.get(key)
|
|
.and_then(JsonValue::as_str)
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
|
|
}
|
|
|
|
fn optional_string<'a>(
|
|
object: &'a BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<Option<&'a str>, ConfigError> {
|
|
match object.get(key) {
|
|
Some(value) => value
|
|
.as_str()
|
|
.map(Some)
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn optional_bool(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<Option<bool>, ConfigError> {
|
|
match object.get(key) {
|
|
Some(value) => value
|
|
.as_bool()
|
|
.map(Some)
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn optional_u16(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<Option<u16>, ConfigError> {
|
|
match object.get(key) {
|
|
Some(value) => {
|
|
let Some(number) = value.as_i64() else {
|
|
return Err(ConfigError::Parse(format!(
|
|
"{context}: field {key} must be an integer"
|
|
)));
|
|
};
|
|
let number = u16::try_from(number).map_err(|_| {
|
|
ConfigError::Parse(format!("{context}: field {key} is out of range"))
|
|
})?;
|
|
Ok(Some(number))
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn optional_string_array(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<Option<Vec<String>>, ConfigError> {
|
|
match object.get(key) {
|
|
Some(value) => {
|
|
let Some(array) = value.as_array() else {
|
|
return Err(ConfigError::Parse(format!(
|
|
"{context}: field {key} must be an array"
|
|
)));
|
|
};
|
|
array
|
|
.iter()
|
|
.map(|item| {
|
|
item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
|
|
ConfigError::Parse(format!(
|
|
"{context}: field {key} must contain only strings"
|
|
))
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map(Some)
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn optional_string_map(
|
|
object: &BTreeMap<String, JsonValue>,
|
|
key: &str,
|
|
context: &str,
|
|
) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
|
|
match object.get(key) {
|
|
Some(value) => {
|
|
let Some(map) = value.as_object() else {
|
|
return Err(ConfigError::Parse(format!(
|
|
"{context}: field {key} must be an object"
|
|
)));
|
|
};
|
|
map.iter()
|
|
.map(|(entry_key, entry_value)| {
|
|
entry_value
|
|
.as_str()
|
|
.map(|text| (entry_key.clone(), text.to_string()))
|
|
.ok_or_else(|| {
|
|
ConfigError::Parse(format!(
|
|
"{context}: field {key} must contain only string values"
|
|
))
|
|
})
|
|
})
|
|
.collect::<Result<BTreeMap<_, _>, _>>()
|
|
.map(Some)
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn deep_merge_objects(
|
|
target: &mut BTreeMap<String, JsonValue>,
|
|
source: &BTreeMap<String, JsonValue>,
|
|
) {
|
|
for (key, value) in source {
|
|
match (target.get_mut(key), value) {
|
|
(Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
|
|
deep_merge_objects(existing, incoming);
|
|
}
|
|
_ => {
|
|
target.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
|
|
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
|
};
|
|
use crate::json::JsonValue;
|
|
use crate::sandbox::FilesystemIsolationMode;
|
|
use std::fs;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
fn temp_dir() -> std::path::PathBuf {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("time should be after epoch")
|
|
.as_nanos();
|
|
std::env::temp_dir().join(format!("runtime-config-{nanos}"))
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_object_settings_files() {
|
|
let root = temp_dir();
|
|
let cwd = root.join("project");
|
|
let home = root.join("home").join(".claude");
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
fs::write(home.join("settings.json"), "[]").expect("write bad settings");
|
|
|
|
let error = ConfigLoader::new(&cwd, &home)
|
|
.load()
|
|
.expect_err("config should fail");
|
|
assert!(error
|
|
.to_string()
|
|
.contains("top-level settings value must be a JSON object"));
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
}
|
|
|
|
#[test]
|
|
fn loads_and_merges_claude_code_config_files_by_precedence() {
|
|
let root = temp_dir();
|
|
let cwd = root.join("project");
|
|
let home = root.join("home").join(".claude");
|
|
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
fs::write(
|
|
home.parent().expect("home parent").join(".claude.json"),
|
|
r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
|
|
)
|
|
.expect("write user compat config");
|
|
fs::write(
|
|
home.join("settings.json"),
|
|
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
|
|
)
|
|
.expect("write user settings");
|
|
fs::write(
|
|
cwd.join(".claude.json"),
|
|
r#"{"model":"project-compat","env":{"B":"2"}}"#,
|
|
)
|
|
.expect("write project compat config");
|
|
fs::write(
|
|
cwd.join(".claude").join("settings.json"),
|
|
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
|
|
)
|
|
.expect("write project settings");
|
|
fs::write(
|
|
cwd.join(".claude").join("settings.local.json"),
|
|
r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
|
|
)
|
|
.expect("write local settings");
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
.load()
|
|
.expect("config should load");
|
|
|
|
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
|
assert_eq!(loaded.loaded_entries().len(), 5);
|
|
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
|
|
assert_eq!(
|
|
loaded.get("model"),
|
|
Some(&JsonValue::String("opus".to_string()))
|
|
);
|
|
assert_eq!(loaded.model(), Some("opus"));
|
|
assert_eq!(
|
|
loaded.permission_mode(),
|
|
Some(ResolvedPermissionMode::WorkspaceWrite)
|
|
);
|
|
assert_eq!(
|
|
loaded
|
|
.get("env")
|
|
.and_then(JsonValue::as_object)
|
|
.expect("env object")
|
|
.len(),
|
|
4
|
|
);
|
|
assert!(loaded
|
|
.get("hooks")
|
|
.and_then(JsonValue::as_object)
|
|
.expect("hooks object")
|
|
.contains_key("PreToolUse"));
|
|
assert!(loaded
|
|
.get("hooks")
|
|
.and_then(JsonValue::as_object)
|
|
.expect("hooks object")
|
|
.contains_key("PostToolUse"));
|
|
assert!(loaded.mcp().get("home").is_some());
|
|
assert!(loaded.mcp().get("project").is_some());
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_sandbox_config() {
|
|
let root = temp_dir();
|
|
let cwd = root.join("project");
|
|
let home = root.join("home").join(".claude");
|
|
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
fs::write(
|
|
cwd.join(".claude").join("settings.local.json"),
|
|
r#"{
|
|
"sandbox": {
|
|
"enabled": true,
|
|
"namespaceRestrictions": false,
|
|
"networkIsolation": true,
|
|
"filesystemMode": "allow-list",
|
|
"allowedMounts": ["logs", "tmp/cache"]
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write local settings");
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
.load()
|
|
.expect("config should load");
|
|
|
|
assert_eq!(loaded.sandbox().enabled, Some(true));
|
|
assert_eq!(loaded.sandbox().namespace_restrictions, Some(false));
|
|
assert_eq!(loaded.sandbox().network_isolation, Some(true));
|
|
assert_eq!(
|
|
loaded.sandbox().filesystem_mode,
|
|
Some(FilesystemIsolationMode::AllowList)
|
|
);
|
|
assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]);
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_typed_mcp_and_oauth_config() {
|
|
let root = temp_dir();
|
|
let cwd = root.join("project");
|
|
let home = root.join("home").join(".claude");
|
|
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
fs::write(
|
|
home.join("settings.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"stdio-server": {
|
|
"command": "uvx",
|
|
"args": ["mcp-server"],
|
|
"env": {"TOKEN": "secret"}
|
|
},
|
|
"remote-server": {
|
|
"type": "http",
|
|
"url": "https://example.test/mcp",
|
|
"headers": {"Authorization": "Bearer token"},
|
|
"headersHelper": "helper.sh",
|
|
"oauth": {
|
|
"clientId": "mcp-client",
|
|
"callbackPort": 7777,
|
|
"authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
|
|
"xaa": true
|
|
}
|
|
}
|
|
},
|
|
"oauth": {
|
|
"clientId": "runtime-client",
|
|
"authorizeUrl": "https://console.test/oauth/authorize",
|
|
"tokenUrl": "https://console.test/oauth/token",
|
|
"callbackPort": 54545,
|
|
"manualRedirectUrl": "https://console.test/oauth/callback",
|
|
"scopes": ["org:read", "user:write"]
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write user settings");
|
|
fs::write(
|
|
cwd.join(".claude").join("settings.local.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"remote-server": {
|
|
"type": "ws",
|
|
"url": "wss://override.test/mcp",
|
|
"headers": {"X-Env": "local"}
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write local settings");
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
.load()
|
|
.expect("config should load");
|
|
|
|
let stdio_server = loaded
|
|
.mcp()
|
|
.get("stdio-server")
|
|
.expect("stdio server should exist");
|
|
assert_eq!(stdio_server.scope, ConfigSource::User);
|
|
assert_eq!(stdio_server.transport(), McpTransport::Stdio);
|
|
|
|
let remote_server = loaded
|
|
.mcp()
|
|
.get("remote-server")
|
|
.expect("remote server should exist");
|
|
assert_eq!(remote_server.scope, ConfigSource::Local);
|
|
assert_eq!(remote_server.transport(), McpTransport::Ws);
|
|
match &remote_server.config {
|
|
McpServerConfig::Ws(config) => {
|
|
assert_eq!(config.url, "wss://override.test/mcp");
|
|
assert_eq!(
|
|
config.headers.get("X-Env").map(String::as_str),
|
|
Some("local")
|
|
);
|
|
}
|
|
other => panic!("expected ws config, got {other:?}"),
|
|
}
|
|
|
|
let oauth = loaded.oauth().expect("oauth config should exist");
|
|
assert_eq!(oauth.client_id, "runtime-client");
|
|
assert_eq!(oauth.callback_port, Some(54_545));
|
|
assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_mcp_server_shapes() {
|
|
let root = temp_dir();
|
|
let cwd = root.join("project");
|
|
let home = root.join("home").join(".claude");
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
fs::write(
|
|
home.join("settings.json"),
|
|
r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
|
|
)
|
|
.expect("write broken settings");
|
|
|
|
let error = ConfigLoader::new(&cwd, &home)
|
|
.load()
|
|
.expect_err("config should fail");
|
|
assert!(error
|
|
.to_string()
|
|
.contains("mcpServers.broken: missing string field url"));
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
}
|
|
}
|