| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532 |
- 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 CLAW_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 RuntimePluginConfig {
- enabled_plugins: BTreeMap<String, bool>,
- external_directories: Vec<String>,
- install_root: Option<String>,
- registry_path: Option<String>,
- bundled_root: Option<String>,
- }
- #[derive(Debug, Clone, PartialEq, Eq, Default)]
- pub struct RuntimeFeatureConfig {
- hooks: RuntimeHookConfig,
- plugins: RuntimePluginConfig,
- mcp: McpConfigCollection,
- oauth: Option<OAuthConfig>,
- model: Option<String>,
- permission_mode: Option<ResolvedPermissionMode>,
- permission_rules: RuntimePermissionRuleConfig,
- sandbox: SandboxConfig,
- }
- #[derive(Debug, Clone, PartialEq, Eq, Default)]
- pub struct RuntimeHookConfig {
- pre_tool_use: Vec<String>,
- post_tool_use: Vec<String>,
- post_tool_use_failure: Vec<String>,
- }
- #[derive(Debug, Clone, PartialEq, Eq, Default)]
- pub struct RuntimePermissionRuleConfig {
- allow: Vec<String>,
- deny: Vec<String>,
- ask: Vec<String>,
- }
- #[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,
- ManagedProxy,
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub enum McpServerConfig {
- Stdio(McpStdioServerConfig),
- Sse(McpRemoteServerConfig),
- Http(McpRemoteServerConfig),
- Ws(McpWebSocketServerConfig),
- Sdk(McpSdkServerConfig),
- ManagedProxy(McpManagedProxyServerConfig),
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct McpStdioServerConfig {
- pub command: String,
- pub args: Vec<String>,
- pub env: BTreeMap<String, String>,
- pub tool_call_timeout_ms: Option<u64>,
- }
- #[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 McpManagedProxyServerConfig {
- 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 = default_config_home();
- Self { cwd, config_home }
- }
- #[must_use]
- pub fn config_home(&self) -> &Path {
- &self.config_home
- }
- #[must_use]
- pub fn discover(&self) -> Vec<ConfigEntry> {
- let user_legacy_path = self.config_home.parent().map_or_else(
- || PathBuf::from(".claw.json"),
- |parent| parent.join(".claw.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(".claw.json"),
- },
- ConfigEntry {
- source: ConfigSource::Project,
- path: self.cwd.join(".claw").join("settings.json"),
- },
- ConfigEntry {
- source: ConfigSource::Local,
- path: self.cwd.join(".claw").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 {
- hooks: parse_optional_hooks_config(&merged_value)?,
- plugins: parse_optional_plugin_config(&merged_value)?,
- 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)?,
- permission_rules: parse_optional_permission_rules(&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 hooks(&self) -> &RuntimeHookConfig {
- &self.feature_config.hooks
- }
- #[must_use]
- pub fn plugins(&self) -> &RuntimePluginConfig {
- &self.feature_config.plugins
- }
- #[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 permission_rules(&self) -> &RuntimePermissionRuleConfig {
- &self.feature_config.permission_rules
- }
- #[must_use]
- pub fn sandbox(&self) -> &SandboxConfig {
- &self.feature_config.sandbox
- }
- }
- impl RuntimeFeatureConfig {
- #[must_use]
- pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
- self.hooks = hooks;
- self
- }
- #[must_use]
- pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self {
- self.plugins = plugins;
- self
- }
- #[must_use]
- pub fn hooks(&self) -> &RuntimeHookConfig {
- &self.hooks
- }
- #[must_use]
- pub fn plugins(&self) -> &RuntimePluginConfig {
- &self.plugins
- }
- #[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 permission_rules(&self) -> &RuntimePermissionRuleConfig {
- &self.permission_rules
- }
- #[must_use]
- pub fn sandbox(&self) -> &SandboxConfig {
- &self.sandbox
- }
- }
- impl RuntimePluginConfig {
- #[must_use]
- pub fn enabled_plugins(&self) -> &BTreeMap<String, bool> {
- &self.enabled_plugins
- }
- #[must_use]
- pub fn external_directories(&self) -> &[String] {
- &self.external_directories
- }
- #[must_use]
- pub fn install_root(&self) -> Option<&str> {
- self.install_root.as_deref()
- }
- #[must_use]
- pub fn registry_path(&self) -> Option<&str> {
- self.registry_path.as_deref()
- }
- #[must_use]
- pub fn bundled_root(&self) -> Option<&str> {
- self.bundled_root.as_deref()
- }
- pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) {
- self.enabled_plugins.insert(plugin_id, enabled);
- }
- #[must_use]
- pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool {
- self.enabled_plugins
- .get(plugin_id)
- .copied()
- .unwrap_or(default_enabled)
- }
- }
- #[must_use]
- pub fn default_config_home() -> PathBuf {
- std::env::var_os("CLAW_CONFIG_HOME")
- .map(PathBuf::from)
- .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claw")))
- .unwrap_or_else(|| PathBuf::from(".claw"))
- }
- impl RuntimeHookConfig {
- #[must_use]
- pub fn new(
- pre_tool_use: Vec<String>,
- post_tool_use: Vec<String>,
- post_tool_use_failure: Vec<String>,
- ) -> Self {
- Self {
- pre_tool_use,
- post_tool_use,
- post_tool_use_failure,
- }
- }
- #[must_use]
- pub fn pre_tool_use(&self) -> &[String] {
- &self.pre_tool_use
- }
- #[must_use]
- pub fn post_tool_use(&self) -> &[String] {
- &self.post_tool_use
- }
- #[must_use]
- pub fn merged(&self, other: &Self) -> Self {
- let mut merged = self.clone();
- merged.extend(other);
- merged
- }
- pub fn extend(&mut self, other: &Self) {
- extend_unique(&mut self.pre_tool_use, other.pre_tool_use());
- extend_unique(&mut self.post_tool_use, other.post_tool_use());
- extend_unique(
- &mut self.post_tool_use_failure,
- other.post_tool_use_failure(),
- );
- }
- #[must_use]
- pub fn post_tool_use_failure(&self) -> &[String] {
- &self.post_tool_use_failure
- }
- }
- impl RuntimePermissionRuleConfig {
- #[must_use]
- pub fn new(allow: Vec<String>, deny: Vec<String>, ask: Vec<String>) -> Self {
- Self { allow, deny, ask }
- }
- #[must_use]
- pub fn allow(&self) -> &[String] {
- &self.allow
- }
- #[must_use]
- pub fn deny(&self) -> &[String] {
- &self.deny
- }
- #[must_use]
- pub fn ask(&self) -> &[String] {
- &self.ask
- }
- }
- 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::ManagedProxy(_) => McpTransport::ManagedProxy,
- }
- }
- }
- 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(".claw.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_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
- let Some(object) = root.as_object() else {
- return Ok(RuntimeHookConfig::default());
- };
- let Some(hooks_value) = object.get("hooks") else {
- return Ok(RuntimeHookConfig::default());
- };
- let hooks = expect_object(hooks_value, "merged settings.hooks")?;
- Ok(RuntimeHookConfig {
- pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
- .unwrap_or_default(),
- post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
- .unwrap_or_default(),
- post_tool_use_failure: optional_string_array(
- hooks,
- "PostToolUseFailure",
- "merged settings.hooks",
- )?
- .unwrap_or_default(),
- })
- }
- fn parse_optional_permission_rules(
- root: &JsonValue,
- ) -> Result<RuntimePermissionRuleConfig, ConfigError> {
- let Some(object) = root.as_object() else {
- return Ok(RuntimePermissionRuleConfig::default());
- };
- let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) else {
- return Ok(RuntimePermissionRuleConfig::default());
- };
- Ok(RuntimePermissionRuleConfig {
- allow: optional_string_array(permissions, "allow", "merged settings.permissions")?
- .unwrap_or_default(),
- deny: optional_string_array(permissions, "deny", "merged settings.permissions")?
- .unwrap_or_default(),
- ask: optional_string_array(permissions, "ask", "merged settings.permissions")?
- .unwrap_or_default(),
- })
- }
- fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
- let Some(object) = root.as_object() else {
- return Ok(RuntimePluginConfig::default());
- };
- let mut config = RuntimePluginConfig::default();
- if let Some(enabled_plugins) = object.get("enabledPlugins") {
- config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
- }
- let Some(plugins_value) = object.get("plugins") else {
- return Ok(config);
- };
- let plugins = expect_object(plugins_value, "merged settings.plugins")?;
- if let Some(enabled_value) = plugins.get("enabled") {
- config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
- }
- config.external_directories =
- optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
- .unwrap_or_default();
- config.install_root =
- optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
- config.registry_path =
- optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
- config.bundled_root =
- optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
- Ok(config)
- }
- 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(),
- tool_call_timeout_ms: optional_u64(object, "toolCallTimeoutMs", context)?,
- })),
- "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::ManagedProxy(McpManagedProxyServerConfig {
- 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_u64(
- object: &BTreeMap<String, JsonValue>,
- key: &str,
- context: &str,
- ) -> Result<Option<u64>, ConfigError> {
- match object.get(key) {
- Some(value) => {
- let Some(number) = value.as_i64() else {
- return Err(ConfigError::Parse(format!(
- "{context}: field {key} must be a non-negative integer"
- )));
- };
- let number = u64::try_from(number).map_err(|_| {
- ConfigError::Parse(format!("{context}: field {key} is out of range"))
- })?;
- Ok(Some(number))
- }
- None => Ok(None),
- }
- }
- fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
- let Some(map) = value.as_object() else {
- return Err(ConfigError::Parse(format!(
- "{context}: expected JSON object"
- )));
- };
- map.iter()
- .map(|(key, value)| {
- value
- .as_bool()
- .map(|enabled| (key.clone(), enabled))
- .ok_or_else(|| {
- ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
- })
- })
- .collect()
- }
- 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());
- }
- }
- }
- }
- fn extend_unique(target: &mut Vec<String>, values: &[String]) {
- for value in values {
- push_unique(target, value.clone());
- }
- }
- fn push_unique(target: &mut Vec<String>, value: String) {
- if !target.iter().any(|existing| existing == &value) {
- target.push(value);
- }
- }
- #[cfg(test)]
- mod tests {
- use super::{
- deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource,
- McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeHookConfig,
- RuntimePluginConfig, CLAW_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(".claw");
- 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"));
- if root.exists() {
- 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(".claw");
- fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
- fs::create_dir_all(&home).expect("home config dir");
- fs::write(
- home.parent().expect("home parent").join(".claw.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","allow":["Read"],"deny":["Bash(rm -rf)"]}}"#,
- )
- .expect("write user settings");
- fs::write(
- cwd.join(".claw.json"),
- r#"{"model":"project-compat","env":{"B":"2"}}"#,
- )
- .expect("write project compat config");
- fs::write(
- cwd.join(".claw").join("settings.json"),
- r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"],"PostToolUseFailure":["project-failure"]},"permissions":{"ask":["Edit"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
- )
- .expect("write project settings");
- fs::write(
- cwd.join(".claw").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!(CLAW_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_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
- assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
- assert_eq!(
- loaded.hooks().post_tool_use_failure(),
- &["project-failure".to_string()]
- );
- assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
- assert_eq!(
- loaded.permission_rules().deny(),
- &["Bash(rm -rf)".to_string()]
- );
- assert_eq!(loaded.permission_rules().ask(), &["Edit".to_string()]);
- 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(".claw");
- fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
- fs::create_dir_all(&home).expect("home config dir");
- fs::write(
- cwd.join(".claw").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(".claw");
- fs::create_dir_all(cwd.join(".claw")).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(".claw").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 parses_plugin_config_from_enabled_plugins() {
- let root = temp_dir();
- let cwd = root.join("project");
- let home = root.join("home").join(".claw");
- fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
- fs::create_dir_all(&home).expect("home config dir");
- fs::write(
- home.join("settings.json"),
- r#"{
- "enabledPlugins": {
- "tool-guard@builtin": true,
- "sample-plugin@external": false
- }
- }"#,
- )
- .expect("write user settings");
- let loaded = ConfigLoader::new(&cwd, &home)
- .load()
- .expect("config should load");
- assert_eq!(
- loaded.plugins().enabled_plugins().get("tool-guard@builtin"),
- Some(&true)
- );
- assert_eq!(
- loaded
- .plugins()
- .enabled_plugins()
- .get("sample-plugin@external"),
- Some(&false)
- );
- fs::remove_dir_all(root).expect("cleanup temp dir");
- }
- #[test]
- fn parses_plugin_config() {
- let root = temp_dir();
- let cwd = root.join("project");
- let home = root.join("home").join(".claw");
- fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
- fs::create_dir_all(&home).expect("home config dir");
- fs::write(
- home.join("settings.json"),
- r#"{
- "enabledPlugins": {
- "core-helpers@builtin": true
- },
- "plugins": {
- "externalDirectories": ["./external-plugins"],
- "installRoot": "plugin-cache/installed",
- "registryPath": "plugin-cache/installed.json",
- "bundledRoot": "./bundled-plugins"
- }
- }"#,
- )
- .expect("write plugin settings");
- let loaded = ConfigLoader::new(&cwd, &home)
- .load()
- .expect("config should load");
- assert_eq!(
- loaded
- .plugins()
- .enabled_plugins()
- .get("core-helpers@builtin"),
- Some(&true)
- );
- assert_eq!(
- loaded.plugins().external_directories(),
- &["./external-plugins".to_string()]
- );
- assert_eq!(
- loaded.plugins().install_root(),
- Some("plugin-cache/installed")
- );
- assert_eq!(
- loaded.plugins().registry_path(),
- Some("plugin-cache/installed.json")
- );
- assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
- fs::remove_dir_all(root).expect("cleanup temp dir");
- }
- #[test]
- fn rejects_invalid_mcp_server_shapes() {
- // given
- let root = temp_dir();
- let cwd = root.join("project");
- let home = root.join("home").join(".claw");
- 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");
- // when
- let error = ConfigLoader::new(&cwd, &home)
- .load()
- .expect_err("config should fail");
- // then
- assert!(error
- .to_string()
- .contains("mcpServers.broken: missing string field url"));
- fs::remove_dir_all(root).expect("cleanup temp dir");
- }
- #[test]
- fn empty_settings_file_loads_defaults() {
- // given
- let root = temp_dir();
- let cwd = root.join("project");
- let home = root.join("home").join(".claw");
- 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 empty settings");
- // when
- let loaded = ConfigLoader::new(&cwd, &home)
- .load()
- .expect("empty settings should still load");
- // then
- assert_eq!(loaded.loaded_entries().len(), 1);
- assert_eq!(loaded.permission_mode(), None);
- assert_eq!(loaded.plugins().enabled_plugins().len(), 0);
- fs::remove_dir_all(root).expect("cleanup temp dir");
- }
- #[test]
- fn deep_merge_objects_merges_nested_maps() {
- // given
- let mut target = JsonValue::parse(r#"{"env":{"A":"1","B":"2"},"model":"haiku"}"#)
- .expect("target JSON should parse")
- .as_object()
- .expect("target should be an object")
- .clone();
- let source =
- JsonValue::parse(r#"{"env":{"B":"override","C":"3"},"sandbox":{"enabled":true}}"#)
- .expect("source JSON should parse")
- .as_object()
- .expect("source should be an object")
- .clone();
- // when
- deep_merge_objects(&mut target, &source);
- // then
- let env = target
- .get("env")
- .and_then(JsonValue::as_object)
- .expect("env should remain an object");
- assert_eq!(env.get("A"), Some(&JsonValue::String("1".to_string())));
- assert_eq!(
- env.get("B"),
- Some(&JsonValue::String("override".to_string()))
- );
- assert_eq!(env.get("C"), Some(&JsonValue::String("3".to_string())));
- assert!(target.contains_key("sandbox"));
- }
- #[test]
- fn permission_mode_aliases_resolve_to_expected_modes() {
- // given / when / then
- assert_eq!(
- parse_permission_mode_label("plan", "test").expect("plan should resolve"),
- ResolvedPermissionMode::ReadOnly
- );
- assert_eq!(
- parse_permission_mode_label("acceptEdits", "test").expect("acceptEdits should resolve"),
- ResolvedPermissionMode::WorkspaceWrite
- );
- assert_eq!(
- parse_permission_mode_label("dontAsk", "test").expect("dontAsk should resolve"),
- ResolvedPermissionMode::DangerFullAccess
- );
- }
- #[test]
- fn hook_config_merge_preserves_uniques() {
- // given
- let base = RuntimeHookConfig::new(
- vec!["pre-a".to_string()],
- vec!["post-a".to_string()],
- vec!["failure-a".to_string()],
- );
- let overlay = RuntimeHookConfig::new(
- vec!["pre-a".to_string(), "pre-b".to_string()],
- vec!["post-a".to_string(), "post-b".to_string()],
- vec!["failure-b".to_string()],
- );
- // when
- let merged = base.merged(&overlay);
- // then
- assert_eq!(
- merged.pre_tool_use(),
- &["pre-a".to_string(), "pre-b".to_string()]
- );
- assert_eq!(
- merged.post_tool_use(),
- &["post-a".to_string(), "post-b".to_string()]
- );
- assert_eq!(
- merged.post_tool_use_failure(),
- &["failure-a".to_string(), "failure-b".to_string()]
- );
- }
- #[test]
- fn plugin_state_falls_back_to_default_for_unknown_plugin() {
- // given
- let mut config = RuntimePluginConfig::default();
- config.set_plugin_state("known".to_string(), true);
- // when / then
- assert!(config.state_for("known", false));
- assert!(config.state_for("missing", true));
- assert!(!config.state_for("missing", false));
- }
- }
|