config.rs 47 KB

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