config.rs 31 KB

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