config.rs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146
  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 RuntimeFeatureConfig {
  33. hooks: RuntimeHookConfig,
  34. mcp: McpConfigCollection,
  35. oauth: Option<OAuthConfig>,
  36. model: Option<String>,
  37. permission_mode: Option<ResolvedPermissionMode>,
  38. permission_rules: RuntimePermissionRuleConfig,
  39. sandbox: SandboxConfig,
  40. }
  41. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  42. pub struct RuntimeHookConfig {
  43. pre_tool_use: Vec<String>,
  44. post_tool_use: Vec<String>,
  45. post_tool_use_failure: Vec<String>,
  46. }
  47. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  48. pub struct RuntimePermissionRuleConfig {
  49. allow: Vec<String>,
  50. deny: Vec<String>,
  51. ask: Vec<String>,
  52. }
  53. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  54. pub struct McpConfigCollection {
  55. servers: BTreeMap<String, ScopedMcpServerConfig>,
  56. }
  57. #[derive(Debug, Clone, PartialEq, Eq)]
  58. pub struct ScopedMcpServerConfig {
  59. pub scope: ConfigSource,
  60. pub config: McpServerConfig,
  61. }
  62. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  63. pub enum McpTransport {
  64. Stdio,
  65. Sse,
  66. Http,
  67. Ws,
  68. Sdk,
  69. ManagedProxy,
  70. }
  71. #[derive(Debug, Clone, PartialEq, Eq)]
  72. pub enum McpServerConfig {
  73. Stdio(McpStdioServerConfig),
  74. Sse(McpRemoteServerConfig),
  75. Http(McpRemoteServerConfig),
  76. Ws(McpWebSocketServerConfig),
  77. Sdk(McpSdkServerConfig),
  78. ManagedProxy(McpManagedProxyServerConfig),
  79. }
  80. #[derive(Debug, Clone, PartialEq, Eq)]
  81. pub struct McpStdioServerConfig {
  82. pub command: String,
  83. pub args: Vec<String>,
  84. pub env: BTreeMap<String, String>,
  85. }
  86. #[derive(Debug, Clone, PartialEq, Eq)]
  87. pub struct McpRemoteServerConfig {
  88. pub url: String,
  89. pub headers: BTreeMap<String, String>,
  90. pub headers_helper: Option<String>,
  91. pub oauth: Option<McpOAuthConfig>,
  92. }
  93. #[derive(Debug, Clone, PartialEq, Eq)]
  94. pub struct McpWebSocketServerConfig {
  95. pub url: String,
  96. pub headers: BTreeMap<String, String>,
  97. pub headers_helper: Option<String>,
  98. }
  99. #[derive(Debug, Clone, PartialEq, Eq)]
  100. pub struct McpSdkServerConfig {
  101. pub name: String,
  102. }
  103. #[derive(Debug, Clone, PartialEq, Eq)]
  104. pub struct McpManagedProxyServerConfig {
  105. pub url: String,
  106. pub id: String,
  107. }
  108. #[derive(Debug, Clone, PartialEq, Eq)]
  109. pub struct McpOAuthConfig {
  110. pub client_id: Option<String>,
  111. pub callback_port: Option<u16>,
  112. pub auth_server_metadata_url: Option<String>,
  113. pub xaa: Option<bool>,
  114. }
  115. #[derive(Debug, Clone, PartialEq, Eq)]
  116. pub struct OAuthConfig {
  117. pub client_id: String,
  118. pub authorize_url: String,
  119. pub token_url: String,
  120. pub callback_port: Option<u16>,
  121. pub manual_redirect_url: Option<String>,
  122. pub scopes: Vec<String>,
  123. }
  124. #[derive(Debug)]
  125. pub enum ConfigError {
  126. Io(std::io::Error),
  127. Parse(String),
  128. }
  129. impl Display for ConfigError {
  130. fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
  131. match self {
  132. Self::Io(error) => write!(f, "{error}"),
  133. Self::Parse(error) => write!(f, "{error}"),
  134. }
  135. }
  136. }
  137. impl std::error::Error for ConfigError {}
  138. impl From<std::io::Error> for ConfigError {
  139. fn from(value: std::io::Error) -> Self {
  140. Self::Io(value)
  141. }
  142. }
  143. #[derive(Debug, Clone, PartialEq, Eq)]
  144. pub struct ConfigLoader {
  145. cwd: PathBuf,
  146. config_home: PathBuf,
  147. }
  148. impl ConfigLoader {
  149. #[must_use]
  150. pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
  151. Self {
  152. cwd: cwd.into(),
  153. config_home: config_home.into(),
  154. }
  155. }
  156. #[must_use]
  157. pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
  158. let cwd = cwd.into();
  159. let config_home = std::env::var_os("CLAW_CONFIG_HOME")
  160. .map(PathBuf::from)
  161. .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claw")))
  162. .unwrap_or_else(|| PathBuf::from(".claw"));
  163. Self { cwd, config_home }
  164. }
  165. #[must_use]
  166. pub fn discover(&self) -> Vec<ConfigEntry> {
  167. let user_legacy_path = self.config_home.parent().map_or_else(
  168. || PathBuf::from(".claw.json"),
  169. |parent| parent.join(".claw.json"),
  170. );
  171. vec![
  172. ConfigEntry {
  173. source: ConfigSource::User,
  174. path: user_legacy_path,
  175. },
  176. ConfigEntry {
  177. source: ConfigSource::User,
  178. path: self.config_home.join("settings.json"),
  179. },
  180. ConfigEntry {
  181. source: ConfigSource::Project,
  182. path: self.cwd.join(".claw.json"),
  183. },
  184. ConfigEntry {
  185. source: ConfigSource::Project,
  186. path: self.cwd.join(".claw").join("settings.json"),
  187. },
  188. ConfigEntry {
  189. source: ConfigSource::Local,
  190. path: self.cwd.join(".claw").join("settings.local.json"),
  191. },
  192. ]
  193. }
  194. pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
  195. let mut merged = BTreeMap::new();
  196. let mut loaded_entries = Vec::new();
  197. let mut mcp_servers = BTreeMap::new();
  198. for entry in self.discover() {
  199. let Some(value) = read_optional_json_object(&entry.path)? else {
  200. continue;
  201. };
  202. merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
  203. deep_merge_objects(&mut merged, &value);
  204. loaded_entries.push(entry);
  205. }
  206. let merged_value = JsonValue::Object(merged.clone());
  207. let feature_config = RuntimeFeatureConfig {
  208. hooks: parse_optional_hooks_config(&merged_value)?,
  209. mcp: McpConfigCollection {
  210. servers: mcp_servers,
  211. },
  212. oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
  213. model: parse_optional_model(&merged_value),
  214. permission_mode: parse_optional_permission_mode(&merged_value)?,
  215. permission_rules: parse_optional_permission_rules(&merged_value)?,
  216. sandbox: parse_optional_sandbox_config(&merged_value)?,
  217. };
  218. Ok(RuntimeConfig {
  219. merged,
  220. loaded_entries,
  221. feature_config,
  222. })
  223. }
  224. }
  225. impl RuntimeConfig {
  226. #[must_use]
  227. pub fn empty() -> Self {
  228. Self {
  229. merged: BTreeMap::new(),
  230. loaded_entries: Vec::new(),
  231. feature_config: RuntimeFeatureConfig::default(),
  232. }
  233. }
  234. #[must_use]
  235. pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
  236. &self.merged
  237. }
  238. #[must_use]
  239. pub fn loaded_entries(&self) -> &[ConfigEntry] {
  240. &self.loaded_entries
  241. }
  242. #[must_use]
  243. pub fn get(&self, key: &str) -> Option<&JsonValue> {
  244. self.merged.get(key)
  245. }
  246. #[must_use]
  247. pub fn as_json(&self) -> JsonValue {
  248. JsonValue::Object(self.merged.clone())
  249. }
  250. #[must_use]
  251. pub fn feature_config(&self) -> &RuntimeFeatureConfig {
  252. &self.feature_config
  253. }
  254. #[must_use]
  255. pub fn mcp(&self) -> &McpConfigCollection {
  256. &self.feature_config.mcp
  257. }
  258. #[must_use]
  259. pub fn hooks(&self) -> &RuntimeHookConfig {
  260. &self.feature_config.hooks
  261. }
  262. #[must_use]
  263. pub fn oauth(&self) -> Option<&OAuthConfig> {
  264. self.feature_config.oauth.as_ref()
  265. }
  266. #[must_use]
  267. pub fn model(&self) -> Option<&str> {
  268. self.feature_config.model.as_deref()
  269. }
  270. #[must_use]
  271. pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
  272. self.feature_config.permission_mode
  273. }
  274. #[must_use]
  275. pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
  276. &self.feature_config.permission_rules
  277. }
  278. #[must_use]
  279. pub fn sandbox(&self) -> &SandboxConfig {
  280. &self.feature_config.sandbox
  281. }
  282. }
  283. impl RuntimeFeatureConfig {
  284. #[must_use]
  285. pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
  286. self.hooks = hooks;
  287. self
  288. }
  289. #[must_use]
  290. pub fn hooks(&self) -> &RuntimeHookConfig {
  291. &self.hooks
  292. }
  293. #[must_use]
  294. pub fn mcp(&self) -> &McpConfigCollection {
  295. &self.mcp
  296. }
  297. #[must_use]
  298. pub fn oauth(&self) -> Option<&OAuthConfig> {
  299. self.oauth.as_ref()
  300. }
  301. #[must_use]
  302. pub fn model(&self) -> Option<&str> {
  303. self.model.as_deref()
  304. }
  305. #[must_use]
  306. pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
  307. self.permission_mode
  308. }
  309. #[must_use]
  310. pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
  311. &self.permission_rules
  312. }
  313. #[must_use]
  314. pub fn sandbox(&self) -> &SandboxConfig {
  315. &self.sandbox
  316. }
  317. }
  318. impl RuntimeHookConfig {
  319. #[must_use]
  320. pub fn new(
  321. pre_tool_use: Vec<String>,
  322. post_tool_use: Vec<String>,
  323. post_tool_use_failure: Vec<String>,
  324. ) -> Self {
  325. Self {
  326. pre_tool_use,
  327. post_tool_use,
  328. post_tool_use_failure,
  329. }
  330. }
  331. #[must_use]
  332. pub fn pre_tool_use(&self) -> &[String] {
  333. &self.pre_tool_use
  334. }
  335. #[must_use]
  336. pub fn post_tool_use(&self) -> &[String] {
  337. &self.post_tool_use
  338. }
  339. #[must_use]
  340. pub fn post_tool_use_failure(&self) -> &[String] {
  341. &self.post_tool_use_failure
  342. }
  343. }
  344. impl RuntimePermissionRuleConfig {
  345. #[must_use]
  346. pub fn new(allow: Vec<String>, deny: Vec<String>, ask: Vec<String>) -> Self {
  347. Self { allow, deny, ask }
  348. }
  349. #[must_use]
  350. pub fn allow(&self) -> &[String] {
  351. &self.allow
  352. }
  353. #[must_use]
  354. pub fn deny(&self) -> &[String] {
  355. &self.deny
  356. }
  357. #[must_use]
  358. pub fn ask(&self) -> &[String] {
  359. &self.ask
  360. }
  361. }
  362. impl McpConfigCollection {
  363. #[must_use]
  364. pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
  365. &self.servers
  366. }
  367. #[must_use]
  368. pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
  369. self.servers.get(name)
  370. }
  371. }
  372. impl ScopedMcpServerConfig {
  373. #[must_use]
  374. pub fn transport(&self) -> McpTransport {
  375. self.config.transport()
  376. }
  377. }
  378. impl McpServerConfig {
  379. #[must_use]
  380. pub fn transport(&self) -> McpTransport {
  381. match self {
  382. Self::Stdio(_) => McpTransport::Stdio,
  383. Self::Sse(_) => McpTransport::Sse,
  384. Self::Http(_) => McpTransport::Http,
  385. Self::Ws(_) => McpTransport::Ws,
  386. Self::Sdk(_) => McpTransport::Sdk,
  387. Self::ManagedProxy(_) => McpTransport::ManagedProxy,
  388. }
  389. }
  390. }
  391. fn read_optional_json_object(
  392. path: &Path,
  393. ) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
  394. let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claw.json");
  395. let contents = match fs::read_to_string(path) {
  396. Ok(contents) => contents,
  397. Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
  398. Err(error) => return Err(ConfigError::Io(error)),
  399. };
  400. if contents.trim().is_empty() {
  401. return Ok(Some(BTreeMap::new()));
  402. }
  403. let parsed = match JsonValue::parse(&contents) {
  404. Ok(parsed) => parsed,
  405. Err(error) if is_legacy_config => return Ok(None),
  406. Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
  407. };
  408. let Some(object) = parsed.as_object() else {
  409. if is_legacy_config {
  410. return Ok(None);
  411. }
  412. return Err(ConfigError::Parse(format!(
  413. "{}: top-level settings value must be a JSON object",
  414. path.display()
  415. )));
  416. };
  417. Ok(Some(object.clone()))
  418. }
  419. fn merge_mcp_servers(
  420. target: &mut BTreeMap<String, ScopedMcpServerConfig>,
  421. source: ConfigSource,
  422. root: &BTreeMap<String, JsonValue>,
  423. path: &Path,
  424. ) -> Result<(), ConfigError> {
  425. let Some(mcp_servers) = root.get("mcpServers") else {
  426. return Ok(());
  427. };
  428. let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
  429. for (name, value) in servers {
  430. let parsed = parse_mcp_server_config(
  431. name,
  432. value,
  433. &format!("{}: mcpServers.{name}", path.display()),
  434. )?;
  435. target.insert(
  436. name.clone(),
  437. ScopedMcpServerConfig {
  438. scope: source,
  439. config: parsed,
  440. },
  441. );
  442. }
  443. Ok(())
  444. }
  445. fn parse_optional_model(root: &JsonValue) -> Option<String> {
  446. root.as_object()
  447. .and_then(|object| object.get("model"))
  448. .and_then(JsonValue::as_str)
  449. .map(ToOwned::to_owned)
  450. }
  451. fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
  452. let Some(object) = root.as_object() else {
  453. return Ok(RuntimeHookConfig::default());
  454. };
  455. let Some(hooks_value) = object.get("hooks") else {
  456. return Ok(RuntimeHookConfig::default());
  457. };
  458. let hooks = expect_object(hooks_value, "merged settings.hooks")?;
  459. Ok(RuntimeHookConfig {
  460. pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
  461. .unwrap_or_default(),
  462. post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
  463. .unwrap_or_default(),
  464. post_tool_use_failure: optional_string_array(
  465. hooks,
  466. "PostToolUseFailure",
  467. "merged settings.hooks",
  468. )?
  469. .unwrap_or_default(),
  470. })
  471. }
  472. fn parse_optional_permission_rules(
  473. root: &JsonValue,
  474. ) -> Result<RuntimePermissionRuleConfig, ConfigError> {
  475. let Some(object) = root.as_object() else {
  476. return Ok(RuntimePermissionRuleConfig::default());
  477. };
  478. let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) else {
  479. return Ok(RuntimePermissionRuleConfig::default());
  480. };
  481. Ok(RuntimePermissionRuleConfig {
  482. allow: optional_string_array(permissions, "allow", "merged settings.permissions")?
  483. .unwrap_or_default(),
  484. deny: optional_string_array(permissions, "deny", "merged settings.permissions")?
  485. .unwrap_or_default(),
  486. ask: optional_string_array(permissions, "ask", "merged settings.permissions")?
  487. .unwrap_or_default(),
  488. })
  489. }
  490. fn parse_optional_permission_mode(
  491. root: &JsonValue,
  492. ) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
  493. let Some(object) = root.as_object() else {
  494. return Ok(None);
  495. };
  496. if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
  497. return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
  498. }
  499. let Some(mode) = object
  500. .get("permissions")
  501. .and_then(JsonValue::as_object)
  502. .and_then(|permissions| permissions.get("defaultMode"))
  503. .and_then(JsonValue::as_str)
  504. else {
  505. return Ok(None);
  506. };
  507. parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
  508. }
  509. fn parse_permission_mode_label(
  510. mode: &str,
  511. context: &str,
  512. ) -> Result<ResolvedPermissionMode, ConfigError> {
  513. match mode {
  514. "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
  515. "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
  516. "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
  517. other => Err(ConfigError::Parse(format!(
  518. "{context}: unsupported permission mode {other}"
  519. ))),
  520. }
  521. }
  522. fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
  523. let Some(object) = root.as_object() else {
  524. return Ok(SandboxConfig::default());
  525. };
  526. let Some(sandbox_value) = object.get("sandbox") else {
  527. return Ok(SandboxConfig::default());
  528. };
  529. let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
  530. let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
  531. .map(parse_filesystem_mode_label)
  532. .transpose()?;
  533. Ok(SandboxConfig {
  534. enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
  535. namespace_restrictions: optional_bool(
  536. sandbox,
  537. "namespaceRestrictions",
  538. "merged settings.sandbox",
  539. )?,
  540. network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
  541. filesystem_mode,
  542. allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
  543. .unwrap_or_default(),
  544. })
  545. }
  546. fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
  547. match value {
  548. "off" => Ok(FilesystemIsolationMode::Off),
  549. "workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
  550. "allow-list" => Ok(FilesystemIsolationMode::AllowList),
  551. other => Err(ConfigError::Parse(format!(
  552. "merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
  553. ))),
  554. }
  555. }
  556. fn parse_optional_oauth_config(
  557. root: &JsonValue,
  558. context: &str,
  559. ) -> Result<Option<OAuthConfig>, ConfigError> {
  560. let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
  561. return Ok(None);
  562. };
  563. let object = expect_object(oauth_value, context)?;
  564. let client_id = expect_string(object, "clientId", context)?.to_string();
  565. let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
  566. let token_url = expect_string(object, "tokenUrl", context)?.to_string();
  567. let callback_port = optional_u16(object, "callbackPort", context)?;
  568. let manual_redirect_url =
  569. optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
  570. let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
  571. Ok(Some(OAuthConfig {
  572. client_id,
  573. authorize_url,
  574. token_url,
  575. callback_port,
  576. manual_redirect_url,
  577. scopes,
  578. }))
  579. }
  580. fn parse_mcp_server_config(
  581. server_name: &str,
  582. value: &JsonValue,
  583. context: &str,
  584. ) -> Result<McpServerConfig, ConfigError> {
  585. let object = expect_object(value, context)?;
  586. let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
  587. match server_type {
  588. "stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
  589. command: expect_string(object, "command", context)?.to_string(),
  590. args: optional_string_array(object, "args", context)?.unwrap_or_default(),
  591. env: optional_string_map(object, "env", context)?.unwrap_or_default(),
  592. })),
  593. "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
  594. object, context,
  595. )?)),
  596. "http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
  597. object, context,
  598. )?)),
  599. "ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
  600. url: expect_string(object, "url", context)?.to_string(),
  601. headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
  602. headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
  603. })),
  604. "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
  605. name: expect_string(object, "name", context)?.to_string(),
  606. })),
  607. "managed-proxy" => Ok(McpServerConfig::ManagedProxy(
  608. McpManagedProxyServerConfig {
  609. url: expect_string(object, "url", context)?.to_string(),
  610. id: expect_string(object, "id", context)?.to_string(),
  611. },
  612. )),
  613. other => Err(ConfigError::Parse(format!(
  614. "{context}: unsupported MCP server type for {server_name}: {other}"
  615. ))),
  616. }
  617. }
  618. fn parse_mcp_remote_server_config(
  619. object: &BTreeMap<String, JsonValue>,
  620. context: &str,
  621. ) -> Result<McpRemoteServerConfig, ConfigError> {
  622. Ok(McpRemoteServerConfig {
  623. url: expect_string(object, "url", context)?.to_string(),
  624. headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
  625. headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
  626. oauth: parse_optional_mcp_oauth_config(object, context)?,
  627. })
  628. }
  629. fn parse_optional_mcp_oauth_config(
  630. object: &BTreeMap<String, JsonValue>,
  631. context: &str,
  632. ) -> Result<Option<McpOAuthConfig>, ConfigError> {
  633. let Some(value) = object.get("oauth") else {
  634. return Ok(None);
  635. };
  636. let oauth = expect_object(value, &format!("{context}.oauth"))?;
  637. Ok(Some(McpOAuthConfig {
  638. client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
  639. callback_port: optional_u16(oauth, "callbackPort", context)?,
  640. auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
  641. .map(str::to_string),
  642. xaa: optional_bool(oauth, "xaa", context)?,
  643. }))
  644. }
  645. fn expect_object<'a>(
  646. value: &'a JsonValue,
  647. context: &str,
  648. ) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
  649. value
  650. .as_object()
  651. .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
  652. }
  653. fn expect_string<'a>(
  654. object: &'a BTreeMap<String, JsonValue>,
  655. key: &str,
  656. context: &str,
  657. ) -> Result<&'a str, ConfigError> {
  658. object
  659. .get(key)
  660. .and_then(JsonValue::as_str)
  661. .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
  662. }
  663. fn optional_string<'a>(
  664. object: &'a BTreeMap<String, JsonValue>,
  665. key: &str,
  666. context: &str,
  667. ) -> Result<Option<&'a str>, ConfigError> {
  668. match object.get(key) {
  669. Some(value) => value
  670. .as_str()
  671. .map(Some)
  672. .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
  673. None => Ok(None),
  674. }
  675. }
  676. fn optional_bool(
  677. object: &BTreeMap<String, JsonValue>,
  678. key: &str,
  679. context: &str,
  680. ) -> Result<Option<bool>, ConfigError> {
  681. match object.get(key) {
  682. Some(value) => value
  683. .as_bool()
  684. .map(Some)
  685. .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
  686. None => Ok(None),
  687. }
  688. }
  689. fn optional_u16(
  690. object: &BTreeMap<String, JsonValue>,
  691. key: &str,
  692. context: &str,
  693. ) -> Result<Option<u16>, ConfigError> {
  694. match object.get(key) {
  695. Some(value) => {
  696. let Some(number) = value.as_i64() else {
  697. return Err(ConfigError::Parse(format!(
  698. "{context}: field {key} must be an integer"
  699. )));
  700. };
  701. let number = u16::try_from(number).map_err(|_| {
  702. ConfigError::Parse(format!("{context}: field {key} is out of range"))
  703. })?;
  704. Ok(Some(number))
  705. }
  706. None => Ok(None),
  707. }
  708. }
  709. fn optional_string_array(
  710. object: &BTreeMap<String, JsonValue>,
  711. key: &str,
  712. context: &str,
  713. ) -> Result<Option<Vec<String>>, ConfigError> {
  714. match object.get(key) {
  715. Some(value) => {
  716. let Some(array) = value.as_array() else {
  717. return Err(ConfigError::Parse(format!(
  718. "{context}: field {key} must be an array"
  719. )));
  720. };
  721. array
  722. .iter()
  723. .map(|item| {
  724. item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
  725. ConfigError::Parse(format!(
  726. "{context}: field {key} must contain only strings"
  727. ))
  728. })
  729. })
  730. .collect::<Result<Vec<_>, _>>()
  731. .map(Some)
  732. }
  733. None => Ok(None),
  734. }
  735. }
  736. fn optional_string_map(
  737. object: &BTreeMap<String, JsonValue>,
  738. key: &str,
  739. context: &str,
  740. ) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
  741. match object.get(key) {
  742. Some(value) => {
  743. let Some(map) = value.as_object() else {
  744. return Err(ConfigError::Parse(format!(
  745. "{context}: field {key} must be an object"
  746. )));
  747. };
  748. map.iter()
  749. .map(|(entry_key, entry_value)| {
  750. entry_value
  751. .as_str()
  752. .map(|text| (entry_key.clone(), text.to_string()))
  753. .ok_or_else(|| {
  754. ConfigError::Parse(format!(
  755. "{context}: field {key} must contain only string values"
  756. ))
  757. })
  758. })
  759. .collect::<Result<BTreeMap<_, _>, _>>()
  760. .map(Some)
  761. }
  762. None => Ok(None),
  763. }
  764. }
  765. fn deep_merge_objects(
  766. target: &mut BTreeMap<String, JsonValue>,
  767. source: &BTreeMap<String, JsonValue>,
  768. ) {
  769. for (key, value) in source {
  770. match (target.get_mut(key), value) {
  771. (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
  772. deep_merge_objects(existing, incoming);
  773. }
  774. _ => {
  775. target.insert(key.clone(), value.clone());
  776. }
  777. }
  778. }
  779. }
  780. #[cfg(test)]
  781. mod tests {
  782. use super::{
  783. ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
  784. CLAW_SETTINGS_SCHEMA_NAME,
  785. };
  786. use crate::json::JsonValue;
  787. use crate::sandbox::FilesystemIsolationMode;
  788. use std::fs;
  789. use std::time::{SystemTime, UNIX_EPOCH};
  790. fn temp_dir() -> std::path::PathBuf {
  791. let nanos = SystemTime::now()
  792. .duration_since(UNIX_EPOCH)
  793. .expect("time should be after epoch")
  794. .as_nanos();
  795. std::env::temp_dir().join(format!("runtime-config-{nanos}"))
  796. }
  797. #[test]
  798. fn rejects_non_object_settings_files() {
  799. let root = temp_dir();
  800. let cwd = root.join("project");
  801. let home = root.join("home").join(".claw");
  802. fs::create_dir_all(&home).expect("home config dir");
  803. fs::create_dir_all(&cwd).expect("project dir");
  804. fs::write(home.join("settings.json"), "[]").expect("write bad settings");
  805. let error = ConfigLoader::new(&cwd, &home)
  806. .load()
  807. .expect_err("config should fail");
  808. assert!(error
  809. .to_string()
  810. .contains("top-level settings value must be a JSON object"));
  811. fs::remove_dir_all(root).expect("cleanup temp dir");
  812. }
  813. #[test]
  814. fn loads_and_merges_claw_config_files_by_precedence() {
  815. let root = temp_dir();
  816. let cwd = root.join("project");
  817. let home = root.join("home").join(".claw");
  818. fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
  819. fs::create_dir_all(&home).expect("home config dir");
  820. fs::write(
  821. home.parent().expect("home parent").join(".claw.json"),
  822. r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
  823. )
  824. .expect("write user compat config");
  825. fs::write(
  826. home.join("settings.json"),
  827. r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan","allow":["Read"],"deny":["Bash(rm -rf)"]}}"#,
  828. )
  829. .expect("write user settings");
  830. fs::write(
  831. cwd.join(".claw.json"),
  832. r#"{"model":"project-compat","env":{"B":"2"}}"#,
  833. )
  834. .expect("write project compat config");
  835. fs::write(
  836. cwd.join(".claw").join("settings.json"),
  837. r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"],"PostToolUseFailure":["project-failure"]},"permissions":{"ask":["Edit"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
  838. )
  839. .expect("write project settings");
  840. fs::write(
  841. cwd.join(".claw").join("settings.local.json"),
  842. r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
  843. )
  844. .expect("write local settings");
  845. let loaded = ConfigLoader::new(&cwd, &home)
  846. .load()
  847. .expect("config should load");
  848. assert_eq!(CLAW_SETTINGS_SCHEMA_NAME, "SettingsSchema");
  849. assert_eq!(loaded.loaded_entries().len(), 5);
  850. assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
  851. assert_eq!(
  852. loaded.get("model"),
  853. Some(&JsonValue::String("opus".to_string()))
  854. );
  855. assert_eq!(loaded.model(), Some("opus"));
  856. assert_eq!(
  857. loaded.permission_mode(),
  858. Some(ResolvedPermissionMode::WorkspaceWrite)
  859. );
  860. assert_eq!(
  861. loaded
  862. .get("env")
  863. .and_then(JsonValue::as_object)
  864. .expect("env object")
  865. .len(),
  866. 4
  867. );
  868. assert!(loaded
  869. .get("hooks")
  870. .and_then(JsonValue::as_object)
  871. .expect("hooks object")
  872. .contains_key("PreToolUse"));
  873. assert!(loaded
  874. .get("hooks")
  875. .and_then(JsonValue::as_object)
  876. .expect("hooks object")
  877. .contains_key("PostToolUse"));
  878. assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
  879. assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
  880. assert_eq!(
  881. loaded.hooks().post_tool_use_failure(),
  882. &["project-failure".to_string()]
  883. );
  884. assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
  885. assert_eq!(
  886. loaded.permission_rules().deny(),
  887. &["Bash(rm -rf)".to_string()]
  888. );
  889. assert_eq!(loaded.permission_rules().ask(), &["Edit".to_string()]);
  890. assert!(loaded.mcp().get("home").is_some());
  891. assert!(loaded.mcp().get("project").is_some());
  892. fs::remove_dir_all(root).expect("cleanup temp dir");
  893. }
  894. #[test]
  895. fn parses_sandbox_config() {
  896. let root = temp_dir();
  897. let cwd = root.join("project");
  898. let home = root.join("home").join(".claw");
  899. fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
  900. fs::create_dir_all(&home).expect("home config dir");
  901. fs::write(
  902. cwd.join(".claw").join("settings.local.json"),
  903. r#"{
  904. "sandbox": {
  905. "enabled": true,
  906. "namespaceRestrictions": false,
  907. "networkIsolation": true,
  908. "filesystemMode": "allow-list",
  909. "allowedMounts": ["logs", "tmp/cache"]
  910. }
  911. }"#,
  912. )
  913. .expect("write local settings");
  914. let loaded = ConfigLoader::new(&cwd, &home)
  915. .load()
  916. .expect("config should load");
  917. assert_eq!(loaded.sandbox().enabled, Some(true));
  918. assert_eq!(loaded.sandbox().namespace_restrictions, Some(false));
  919. assert_eq!(loaded.sandbox().network_isolation, Some(true));
  920. assert_eq!(
  921. loaded.sandbox().filesystem_mode,
  922. Some(FilesystemIsolationMode::AllowList)
  923. );
  924. assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]);
  925. fs::remove_dir_all(root).expect("cleanup temp dir");
  926. }
  927. #[test]
  928. fn parses_typed_mcp_and_oauth_config() {
  929. let root = temp_dir();
  930. let cwd = root.join("project");
  931. let home = root.join("home").join(".claw");
  932. fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
  933. fs::create_dir_all(&home).expect("home config dir");
  934. fs::write(
  935. home.join("settings.json"),
  936. r#"{
  937. "mcpServers": {
  938. "stdio-server": {
  939. "command": "uvx",
  940. "args": ["mcp-server"],
  941. "env": {"TOKEN": "secret"}
  942. },
  943. "remote-server": {
  944. "type": "http",
  945. "url": "https://example.test/mcp",
  946. "headers": {"Authorization": "Bearer token"},
  947. "headersHelper": "helper.sh",
  948. "oauth": {
  949. "clientId": "mcp-client",
  950. "callbackPort": 7777,
  951. "authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
  952. "xaa": true
  953. }
  954. }
  955. },
  956. "oauth": {
  957. "clientId": "runtime-client",
  958. "authorizeUrl": "https://console.test/oauth/authorize",
  959. "tokenUrl": "https://console.test/oauth/token",
  960. "callbackPort": 54545,
  961. "manualRedirectUrl": "https://console.test/oauth/callback",
  962. "scopes": ["org:read", "user:write"]
  963. }
  964. }"#,
  965. )
  966. .expect("write user settings");
  967. fs::write(
  968. cwd.join(".claw").join("settings.local.json"),
  969. r#"{
  970. "mcpServers": {
  971. "remote-server": {
  972. "type": "ws",
  973. "url": "wss://override.test/mcp",
  974. "headers": {"X-Env": "local"}
  975. }
  976. }
  977. }"#,
  978. )
  979. .expect("write local settings");
  980. let loaded = ConfigLoader::new(&cwd, &home)
  981. .load()
  982. .expect("config should load");
  983. let stdio_server = loaded
  984. .mcp()
  985. .get("stdio-server")
  986. .expect("stdio server should exist");
  987. assert_eq!(stdio_server.scope, ConfigSource::User);
  988. assert_eq!(stdio_server.transport(), McpTransport::Stdio);
  989. let remote_server = loaded
  990. .mcp()
  991. .get("remote-server")
  992. .expect("remote server should exist");
  993. assert_eq!(remote_server.scope, ConfigSource::Local);
  994. assert_eq!(remote_server.transport(), McpTransport::Ws);
  995. match &remote_server.config {
  996. McpServerConfig::Ws(config) => {
  997. assert_eq!(config.url, "wss://override.test/mcp");
  998. assert_eq!(
  999. config.headers.get("X-Env").map(String::as_str),
  1000. Some("local")
  1001. );
  1002. }
  1003. other => panic!("expected ws config, got {other:?}"),
  1004. }
  1005. let oauth = loaded.oauth().expect("oauth config should exist");
  1006. assert_eq!(oauth.client_id, "runtime-client");
  1007. assert_eq!(oauth.callback_port, Some(54_545));
  1008. assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
  1009. fs::remove_dir_all(root).expect("cleanup temp dir");
  1010. }
  1011. #[test]
  1012. fn rejects_invalid_mcp_server_shapes() {
  1013. let root = temp_dir();
  1014. let cwd = root.join("project");
  1015. let home = root.join("home").join(".claw");
  1016. fs::create_dir_all(&home).expect("home config dir");
  1017. fs::create_dir_all(&cwd).expect("project dir");
  1018. fs::write(
  1019. home.join("settings.json"),
  1020. r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
  1021. )
  1022. .expect("write broken settings");
  1023. let error = ConfigLoader::new(&cwd, &home)
  1024. .load()
  1025. .expect_err("config should fail");
  1026. assert!(error
  1027. .to_string()
  1028. .contains("mcpServers.broken: missing string field url"));
  1029. fs::remove_dir_all(root).expect("cleanup temp dir");
  1030. }
  1031. }