diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 559ae6a..9ea937e 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -14,6 +14,13 @@ pub enum ConfigSource { Local, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolvedPermissionMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigEntry { pub source: ConfigSource, @@ -31,6 +38,8 @@ pub struct RuntimeConfig { pub struct RuntimeFeatureConfig { mcp: McpConfigCollection, oauth: Option, + model: Option, + permission_mode: Option, } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -165,11 +174,23 @@ impl ConfigLoader { #[must_use] pub fn discover(&self) -> Vec { + let user_legacy_path = self.config_home.parent().map_or_else( + || PathBuf::from(".claude.json"), + |parent| parent.join(".claude.json"), + ); vec![ + ConfigEntry { + source: ConfigSource::User, + path: user_legacy_path, + }, ConfigEntry { source: ConfigSource::User, path: self.config_home.join("settings.json"), }, + ConfigEntry { + source: ConfigSource::Project, + path: self.cwd.join(".claude.json"), + }, ConfigEntry { source: ConfigSource::Project, path: self.cwd.join(".claude").join("settings.json"), @@ -195,14 +216,15 @@ impl ConfigLoader { loaded_entries.push(entry); } + let merged_value = JsonValue::Object(merged.clone()); + let feature_config = RuntimeFeatureConfig { mcp: McpConfigCollection { servers: mcp_servers, }, - oauth: parse_optional_oauth_config( - &JsonValue::Object(merged.clone()), - "merged settings.oauth", - )?, + oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, + model: parse_optional_model(&merged_value), + permission_mode: parse_optional_permission_mode(&merged_value)?, }; Ok(RuntimeConfig { @@ -257,6 +279,16 @@ impl RuntimeConfig { pub fn oauth(&self) -> Option<&OAuthConfig> { self.feature_config.oauth.as_ref() } + + #[must_use] + pub fn model(&self) -> Option<&str> { + self.feature_config.model.as_deref() + } + + #[must_use] + pub fn permission_mode(&self) -> Option { + self.feature_config.permission_mode + } } impl RuntimeFeatureConfig { @@ -269,6 +301,16 @@ impl RuntimeFeatureConfig { pub fn oauth(&self) -> Option<&OAuthConfig> { self.oauth.as_ref() } + + #[must_use] + pub fn model(&self) -> Option<&str> { + self.model.as_deref() + } + + #[must_use] + pub fn permission_mode(&self) -> Option { + self.permission_mode + } } impl McpConfigCollection { @@ -307,6 +349,7 @@ impl McpServerConfig { fn read_optional_json_object( path: &Path, ) -> Result>, ConfigError> { + let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json"); let contents = match fs::read_to_string(path) { Ok(contents) => contents, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), @@ -317,14 +360,20 @@ fn read_optional_json_object( return Ok(Some(BTreeMap::new())); } - let parsed = JsonValue::parse(&contents) - .map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?; - let object = parsed.as_object().ok_or_else(|| { - ConfigError::Parse(format!( + let parsed = match JsonValue::parse(&contents) { + Ok(parsed) => parsed, + Err(error) if is_legacy_config => return Ok(None), + Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))), + }; + let Some(object) = parsed.as_object() else { + if is_legacy_config { + return Ok(None); + } + return Err(ConfigError::Parse(format!( "{}: top-level settings value must be a JSON object", path.display() - )) - })?; + ))); + }; Ok(Some(object.clone())) } @@ -355,6 +404,47 @@ fn merge_mcp_servers( Ok(()) } +fn parse_optional_model(root: &JsonValue) -> Option { + root.as_object() + .and_then(|object| object.get("model")) + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned) +} + +fn parse_optional_permission_mode( + root: &JsonValue, +) -> Result, ConfigError> { + let Some(object) = root.as_object() else { + return Ok(None); + }; + if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) { + return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some); + } + let Some(mode) = object + .get("permissions") + .and_then(JsonValue::as_object) + .and_then(|permissions| permissions.get("defaultMode")) + .and_then(JsonValue::as_str) + else { + return Ok(None); + }; + parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some) +} + +fn parse_permission_mode_label( + mode: &str, + context: &str, +) -> Result { + match mode { + "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly), + "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite), + "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess), + other => Err(ConfigError::Parse(format!( + "{context}: unsupported permission mode {other}" + ))), + } +} + fn parse_optional_oauth_config( root: &JsonValue, context: &str, @@ -594,7 +684,8 @@ fn deep_merge_objects( #[cfg(test)] mod tests { use super::{ - ConfigLoader, ConfigSource, McpServerConfig, McpTransport, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, + ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode, + CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; use crate::json::JsonValue; use std::fs; @@ -635,14 +726,24 @@ mod tests { fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); fs::create_dir_all(&home).expect("home config dir"); + fs::write( + home.parent().expect("home parent").join(".claude.json"), + r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#, + ) + .expect("write user compat config"); fs::write( home.join("settings.json"), - r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#, + r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#, ) .expect("write user settings"); + fs::write( + cwd.join(".claude.json"), + r#"{"model":"project-compat","env":{"B":"2"}}"#, + ) + .expect("write project compat config"); fs::write( cwd.join(".claude").join("settings.json"), - r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#, + r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#, ) .expect("write project settings"); fs::write( @@ -656,25 +757,37 @@ mod tests { .expect("config should load"); assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema"); - assert_eq!(loaded.loaded_entries().len(), 3); + assert_eq!(loaded.loaded_entries().len(), 5); assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User); assert_eq!( loaded.get("model"), Some(&JsonValue::String("opus".to_string())) ); + assert_eq!(loaded.model(), Some("opus")); + assert_eq!( + loaded.permission_mode(), + Some(ResolvedPermissionMode::WorkspaceWrite) + ); assert_eq!( loaded .get("env") .and_then(JsonValue::as_object) .expect("env object") .len(), - 2 + 4 ); assert!(loaded .get("hooks") .and_then(JsonValue::as_object) .expect("hooks object") .contains_key("PreToolUse")); + assert!(loaded + .get("hooks") + .and_then(JsonValue::as_object) + .expect("hooks object") + .contains_key("PostToolUse")); + assert!(loaded.mcp().get("home").is_some()); + assert!(loaded.mcp().get("project").is_some()); fs::remove_dir_all(root).expect("cleanup temp dir"); } diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 1ed56b9..5c9ccfe 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -408,8 +408,7 @@ mod tests { .sum::(); Ok(total.to_string()) }); - let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite) - .with_tool_requirement("add", PermissionMode::DangerFullAccess); + let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); let system_prompt = SystemPromptBuilder::new() .with_project_context(ProjectContext { cwd: PathBuf::from("/tmp/project"), @@ -488,8 +487,7 @@ mod tests { Session::new(), SingleCallApiClient, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::WorkspaceWrite) - .with_tool_requirement("blocked", PermissionMode::DangerFullAccess), + PermissionPolicy::new(PermissionMode::Prompt), vec!["system".to_string()], ); @@ -538,7 +536,7 @@ mod tests { session, SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::ReadOnly), + PermissionPolicy::new(PermissionMode::Allow), vec!["system".to_string()], ); @@ -565,7 +563,7 @@ mod tests { Session::new(), SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::ReadOnly), + PermissionPolicy::new(PermissionMode::Allow), vec!["system".to_string()], ); runtime.run_turn("a", None).expect("turn a"); diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 816ace0..a13ae2d 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -25,7 +25,8 @@ pub use config::{ ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig, McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, - RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, + ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, + CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; pub use conversation::{ ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, @@ -76,3 +77,11 @@ pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, Sessi pub use usage::{ format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, }; + +#[cfg(test)] +pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/rust/crates/runtime/src/oauth.rs b/rust/crates/runtime/src/oauth.rs index db68bf9..3f30a00 100644 --- a/rust/crates/runtime/src/oauth.rs +++ b/rust/crates/runtime/src/oauth.rs @@ -448,7 +448,6 @@ fn decode_hex(byte: u8) -> Result { #[cfg(test)] mod tests { - use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use super::{ @@ -470,10 +469,7 @@ mod tests { } fn env_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .expect("env lock") + crate::test_env_lock() } fn temp_config_home() -> std::path::PathBuf { diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index 99eae97..da213f2 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -201,6 +201,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result> { dir.join("CLAUDE.md"), dir.join("CLAUDE.local.md"), dir.join(".claude").join("CLAUDE.md"), + dir.join(".claude").join("instructions.md"), ] { push_context_file(&mut files, candidate)?; } @@ -468,6 +469,10 @@ mod tests { std::env::temp_dir().join(format!("runtime-prompt-{nanos}")) } + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + crate::test_env_lock() + } + #[test] fn discovers_instruction_files_from_ancestor_chain() { let root = temp_dir(); @@ -477,10 +482,21 @@ mod tests { fs::write(root.join("CLAUDE.local.md"), "local instructions") .expect("write local instructions"); fs::create_dir_all(root.join("apps")).expect("apps dir"); + fs::create_dir_all(root.join("apps").join(".claude")).expect("apps claude dir"); fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions") .expect("write apps instructions"); + fs::write( + root.join("apps").join(".claude").join("instructions.md"), + "apps dot claude instructions", + ) + .expect("write apps dot claude instructions"); fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules") .expect("write nested rules"); + fs::write( + nested.join(".claude").join("instructions.md"), + "nested instructions", + ) + .expect("write nested instructions"); let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load"); let contents = context @@ -495,7 +511,9 @@ mod tests { "root instructions", "local instructions", "apps instructions", - "nested rules" + "apps dot claude instructions", + "nested rules", + "nested instructions" ] ); fs::remove_dir_all(root).expect("cleanup temp dir"); @@ -574,7 +592,12 @@ mod tests { ) .expect("write settings"); + let _guard = env_lock(); let previous = std::env::current_dir().expect("cwd"); + let original_home = std::env::var("HOME").ok(); + let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok(); + std::env::set_var("HOME", &root); + std::env::set_var("CLAUDE_CONFIG_HOME", root.join("missing-home")); std::env::set_current_dir(&root).expect("change cwd"); let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8") .expect("system prompt should load") @@ -584,6 +607,16 @@ mod tests { ", ); std::env::set_current_dir(previous).expect("restore cwd"); + if let Some(value) = original_home { + std::env::set_var("HOME", value); + } else { + std::env::remove_var("HOME"); + } + if let Some(value) = original_claude_home { + std::env::set_var("CLAUDE_CONFIG_HOME", value); + } else { + std::env::remove_var("CLAUDE_CONFIG_HOME"); + } assert!(prompt.contains("Project rules")); assert!(prompt.contains("permissionMode")); @@ -631,6 +664,29 @@ mod tests { assert!(rendered.chars().count() <= 4_000 + "\n\n[truncated]".chars().count()); } + #[test] + fn discovers_dot_claude_instructions_markdown() { + let root = temp_dir(); + let nested = root.join("apps").join("api"); + fs::create_dir_all(nested.join(".claude")).expect("nested claude dir"); + fs::write( + nested.join(".claude").join("instructions.md"), + "instruction markdown", + ) + .expect("write instructions.md"); + + let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load"); + assert!(context + .instruction_files + .iter() + .any(|file| file.path.ends_with(".claude/instructions.md"))); + assert!( + render_instruction_files(&context.instruction_files).contains("instruction markdown") + ); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn renders_instruction_file_metadata() { let rendered = render_instruction_files(&[ContextFile {