2026-05-25 14:11:37 +09:00
|
|
|
use std::collections::{BTreeMap, HashSet};
|
2026-04-01 03:55:00 +00:00
|
|
|
use std::fmt::{Display, Formatter};
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
2026-05-25 14:11:37 +09:00
|
|
|
use std::sync::Mutex;
|
|
|
|
|
|
|
|
|
|
/// Process-lifetime set of already-emitted config deprecation warning strings.
|
|
|
|
|
/// Prevents duplicate warnings when `ConfigLoader::load()` is called multiple
|
|
|
|
|
/// times within a single CLI invocation. (ROADMAP #698)
|
|
|
|
|
static EMITTED_CONFIG_WARNINGS: std::sync::OnceLock<Mutex<HashSet<String>>> =
|
|
|
|
|
std::sync::OnceLock::new();
|
|
|
|
|
|
2026-05-29 14:00:32 +09:00
|
|
|
/// When set to `true`, `emit_config_warning_once` silently drops all prose
|
|
|
|
|
/// deprecation warnings instead of writing them to stderr. Set this flag
|
|
|
|
|
/// before any settings load when `--output-format json` is active so that
|
|
|
|
|
/// JSON-mode machine consumers see empty stderr on success. (#824)
|
|
|
|
|
static SUPPRESS_CONFIG_WARNINGS_STDERR: std::sync::atomic::AtomicBool =
|
|
|
|
|
std::sync::atomic::AtomicBool::new(false);
|
|
|
|
|
|
|
|
|
|
/// Call this once at startup when `--output-format json` is active.
|
|
|
|
|
pub fn suppress_config_warnings_for_json_mode() {
|
|
|
|
|
SUPPRESS_CONFIG_WARNINGS_STDERR.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 14:11:37 +09:00
|
|
|
fn emit_config_warning_once(warning: &str) {
|
2026-05-29 14:00:32 +09:00
|
|
|
if SUPPRESS_CONFIG_WARNINGS_STDERR.load(std::sync::atomic::Ordering::Relaxed) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-25 14:11:37 +09:00
|
|
|
let set = EMITTED_CONFIG_WARNINGS.get_or_init(|| Mutex::new(HashSet::new()));
|
|
|
|
|
let mut guard = set.lock().unwrap_or_else(|e| e.into_inner());
|
|
|
|
|
if guard.insert(warning.to_string()) {
|
|
|
|
|
eprintln!("warning: {warning}");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-01 03:55:00 +00:00
|
|
|
|
|
|
|
|
use crate::json::JsonValue;
|
|
|
|
|
use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Schema name advertised by generated settings files.
|
2026-04-01 18:48:39 +09:00
|
|
|
pub const CLAW_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
|
2026-04-01 03:55:00 +00:00
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Origin of a loaded settings file in the configuration precedence chain.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
|
|
|
pub enum ConfigSource {
|
|
|
|
|
User,
|
|
|
|
|
Project,
|
|
|
|
|
Local,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Effective permission mode after decoding config values.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum ResolvedPermissionMode {
|
|
|
|
|
ReadOnly,
|
|
|
|
|
WorkspaceWrite,
|
|
|
|
|
DangerFullAccess,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// A discovered config file and the scope it contributes to.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct ConfigEntry {
|
|
|
|
|
pub source: ConfigSource,
|
|
|
|
|
pub path: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Fully merged runtime configuration plus parsed feature-specific views.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct RuntimeConfig {
|
|
|
|
|
merged: BTreeMap<String, JsonValue>,
|
|
|
|
|
loaded_entries: Vec<ConfigEntry>,
|
|
|
|
|
feature_config: RuntimeFeatureConfig,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:46:47 +09:00
|
|
|
/// Machine-readable load state for a discovered config file.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum ConfigFileStatus {
|
|
|
|
|
Loaded,
|
|
|
|
|
NotFound,
|
|
|
|
|
Skipped,
|
|
|
|
|
LoadError,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ConfigFileStatus {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn as_str(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Loaded => "loaded",
|
|
|
|
|
Self::NotFound => "not_found",
|
|
|
|
|
Self::Skipped => "skipped",
|
|
|
|
|
Self::LoadError => "load_error",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Structured status for one discovered config file.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct ConfigFileReport {
|
|
|
|
|
pub entry: ConfigEntry,
|
|
|
|
|
pub loaded: bool,
|
|
|
|
|
pub status: ConfigFileStatus,
|
|
|
|
|
pub reason: Option<String>,
|
|
|
|
|
pub detail: Option<String>,
|
2026-06-03 23:47:27 +09:00
|
|
|
pub precedence_rank: usize,
|
|
|
|
|
pub wins_for_keys: Vec<String>,
|
|
|
|
|
pub shadowed_keys: Vec<String>,
|
|
|
|
|
key_paths: Vec<String>,
|
2026-06-03 21:46:47 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Best-effort inspection of the config discovery and load pipeline.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct ConfigInspection {
|
|
|
|
|
pub files: Vec<ConfigFileReport>,
|
|
|
|
|
pub runtime_config: Option<RuntimeConfig>,
|
|
|
|
|
pub warnings: Vec<String>,
|
|
|
|
|
pub load_error: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Parsed plugin-related settings extracted from runtime config.
|
2026-04-01 04:30:28 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
|
|
|
pub struct RuntimePluginConfig {
|
|
|
|
|
enabled_plugins: BTreeMap<String, bool>,
|
|
|
|
|
external_directories: Vec<String>,
|
|
|
|
|
install_root: Option<String>,
|
|
|
|
|
registry_path: Option<String>,
|
|
|
|
|
bundled_root: Option<String>,
|
2026-04-07 15:52:11 +09:00
|
|
|
max_output_tokens: Option<u32>,
|
2026-04-01 04:30:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Structured feature configuration consumed by runtime subsystems.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
|
|
|
pub struct RuntimeFeatureConfig {
|
|
|
|
|
hooks: RuntimeHookConfig,
|
2026-04-01 04:30:28 +00:00
|
|
|
plugins: RuntimePluginConfig,
|
2026-04-01 03:55:00 +00:00
|
|
|
mcp: McpConfigCollection,
|
|
|
|
|
oauth: Option<OAuthConfig>,
|
|
|
|
|
model: Option<String>,
|
2026-04-07 14:51:12 +09:00
|
|
|
aliases: BTreeMap<String, String>,
|
2026-04-01 03:55:00 +00:00
|
|
|
permission_mode: Option<ResolvedPermissionMode>,
|
2026-04-01 04:20:16 +00:00
|
|
|
permission_rules: RuntimePermissionRuleConfig,
|
2026-04-01 03:55:00 +00:00
|
|
|
sandbox: SandboxConfig,
|
2026-04-07 14:51:12 +09:00
|
|
|
provider_fallbacks: ProviderFallbackConfig,
|
2026-04-08 02:35:19 +09:00
|
|
|
trusted_roots: Vec<String>,
|
2026-06-03 21:01:48 +09:00
|
|
|
rules_import: RulesImportConfig,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Controls which external AI coding framework rules are imported into the system prompt.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
|
|
|
pub enum RulesImportConfig {
|
|
|
|
|
/// Import from all supported frameworks when files are detected.
|
|
|
|
|
#[default]
|
|
|
|
|
Auto,
|
|
|
|
|
/// Do not import external framework rules; keep Claw instruction files only.
|
|
|
|
|
None,
|
|
|
|
|
/// Import only the named frameworks.
|
|
|
|
|
List(Vec<String>),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RulesImportConfig {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn should_import(&self, framework: &str) -> bool {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Auto => true,
|
|
|
|
|
Self::None => false,
|
|
|
|
|
Self::List(frameworks) => frameworks
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|candidate| candidate.eq_ignore_ascii_case(framework)),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-07 14:51:12 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Ordered chain of fallback model identifiers used when the primary
|
|
|
|
|
/// provider returns a retryable failure (429/500/503/etc.). The chain is
|
|
|
|
|
/// strict: each entry is tried in order until one succeeds.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
|
|
|
pub struct ProviderFallbackConfig {
|
|
|
|
|
primary: Option<String>,
|
|
|
|
|
fallbacks: Vec<String>,
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Hook command lists grouped by lifecycle stage.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
|
|
|
pub struct RuntimeHookConfig {
|
2026-06-03 21:23:00 +09:00
|
|
|
pre_tool_use: Vec<RuntimeHookCommand>,
|
|
|
|
|
post_tool_use: Vec<RuntimeHookCommand>,
|
|
|
|
|
post_tool_use_failure: Vec<RuntimeHookCommand>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A hook command plus optional tool matcher from object-style hook config.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct RuntimeHookCommand {
|
|
|
|
|
command: String,
|
|
|
|
|
matcher: Option<String>,
|
2026-04-01 04:20:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Raw permission rule lists grouped by allow, deny, and ask behavior.
|
2026-04-01 04:20:16 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
|
|
|
pub struct RuntimePermissionRuleConfig {
|
|
|
|
|
allow: Vec<String>,
|
|
|
|
|
deny: Vec<String>,
|
|
|
|
|
ask: Vec<String>,
|
2026-05-25 12:01:09 +09:00
|
|
|
/// #159: simple tool-name denials parsed from the `deniedTools` config field.
|
|
|
|
|
/// Unlike the `deny` rules (pattern-based), `denied_tools` is a flat list of
|
|
|
|
|
/// tool names that are unconditionally denied regardless of permission mode.
|
|
|
|
|
denied_tools: Vec<String>,
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Collection of configured MCP servers after scope-aware merging.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
|
|
|
pub struct McpConfigCollection {
|
|
|
|
|
servers: BTreeMap<String, ScopedMcpServerConfig>,
|
2026-06-04 18:31:58 +09:00
|
|
|
invalid_servers: Vec<McpInvalidServerConfig>,
|
|
|
|
|
total_configured: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct McpInvalidServerConfig {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub scope: ConfigSource,
|
|
|
|
|
pub path: PathBuf,
|
|
|
|
|
pub error_field: String,
|
|
|
|
|
pub reason: String,
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct ScopedMcpServerConfig {
|
2026-05-15 10:00:39 +09:00
|
|
|
pub required: bool,
|
2026-04-01 03:55:00 +00:00
|
|
|
pub scope: ConfigSource,
|
|
|
|
|
pub config: McpServerConfig,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Transport families supported by configured MCP servers.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum McpTransport {
|
|
|
|
|
Stdio,
|
|
|
|
|
Sse,
|
|
|
|
|
Http,
|
|
|
|
|
Ws,
|
|
|
|
|
Sdk,
|
2026-04-01 18:48:39 +09:00
|
|
|
ManagedProxy,
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Scope-normalized MCP server configuration variants.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub enum McpServerConfig {
|
|
|
|
|
Stdio(McpStdioServerConfig),
|
|
|
|
|
Sse(McpRemoteServerConfig),
|
|
|
|
|
Http(McpRemoteServerConfig),
|
|
|
|
|
Ws(McpWebSocketServerConfig),
|
|
|
|
|
Sdk(McpSdkServerConfig),
|
2026-04-01 18:48:39 +09:00
|
|
|
ManagedProxy(McpManagedProxyServerConfig),
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Configuration for an MCP server launched as a local stdio process.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct McpStdioServerConfig {
|
|
|
|
|
pub command: String,
|
|
|
|
|
pub args: Vec<String>,
|
|
|
|
|
pub env: BTreeMap<String, String>,
|
2026-04-02 18:24:30 +09:00
|
|
|
pub tool_call_timeout_ms: Option<u64>,
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Configuration for an MCP server reached over HTTP or SSE.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct McpRemoteServerConfig {
|
|
|
|
|
pub url: String,
|
|
|
|
|
pub headers: BTreeMap<String, String>,
|
|
|
|
|
pub headers_helper: Option<String>,
|
|
|
|
|
pub oauth: Option<McpOAuthConfig>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Configuration for an MCP server reached over WebSocket.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct McpWebSocketServerConfig {
|
|
|
|
|
pub url: String,
|
|
|
|
|
pub headers: BTreeMap<String, String>,
|
|
|
|
|
pub headers_helper: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Configuration for an MCP server addressed through an SDK name.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct McpSdkServerConfig {
|
|
|
|
|
pub name: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Configuration for an MCP managed-proxy endpoint.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
2026-04-01 18:48:39 +09:00
|
|
|
pub struct McpManagedProxyServerConfig {
|
2026-04-01 03:55:00 +00:00
|
|
|
pub url: String,
|
|
|
|
|
pub id: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// OAuth overrides associated with a remote MCP server.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct McpOAuthConfig {
|
|
|
|
|
pub client_id: Option<String>,
|
|
|
|
|
pub callback_port: Option<u16>,
|
|
|
|
|
pub auth_server_metadata_url: Option<String>,
|
|
|
|
|
pub xaa: Option<bool>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// OAuth client configuration used by the main Claw runtime.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct OAuthConfig {
|
|
|
|
|
pub client_id: String,
|
|
|
|
|
pub authorize_url: String,
|
|
|
|
|
pub token_url: String,
|
|
|
|
|
pub callback_port: Option<u16>,
|
|
|
|
|
pub manual_redirect_url: Option<String>,
|
|
|
|
|
pub scopes: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Errors raised while reading or parsing runtime configuration files.
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub enum ConfigError {
|
|
|
|
|
Io(std::io::Error),
|
|
|
|
|
Parse(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Display for ConfigError {
|
|
|
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Io(error) => write!(f, "{error}"),
|
2026-05-27 01:23:00 +09:00
|
|
|
Self::Parse(error) => write!(
|
|
|
|
|
f,
|
|
|
|
|
"{error}\nFix: open the file shown above and correct the JSON syntax, then retry."
|
|
|
|
|
),
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::error::Error for ConfigError {}
|
|
|
|
|
|
|
|
|
|
impl From<std::io::Error> for ConfigError {
|
|
|
|
|
fn from(value: std::io::Error) -> Self {
|
|
|
|
|
Self::Io(value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Discovers config files and merges them into a [`RuntimeConfig`].
|
2026-04-01 03:55:00 +00:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct ConfigLoader {
|
|
|
|
|
cwd: PathBuf,
|
|
|
|
|
config_home: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ConfigLoader {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
cwd: cwd.into(),
|
|
|
|
|
config_home: config_home.into(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
|
|
|
|
|
let cwd = cwd.into();
|
2026-04-01 04:30:28 +00:00
|
|
|
let config_home = default_config_home();
|
2026-04-01 03:55:00 +00:00
|
|
|
Self { cwd, config_home }
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:30:28 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn config_home(&self) -> &Path {
|
|
|
|
|
&self.config_home
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn discover(&self) -> Vec<ConfigEntry> {
|
|
|
|
|
let user_legacy_path = self.config_home.parent().map_or_else(
|
2026-04-01 18:48:39 +09:00
|
|
|
|| PathBuf::from(".claw.json"),
|
|
|
|
|
|parent| parent.join(".claw.json"),
|
2026-04-01 03:55:00 +00:00
|
|
|
);
|
|
|
|
|
vec![
|
|
|
|
|
ConfigEntry {
|
|
|
|
|
source: ConfigSource::User,
|
|
|
|
|
path: user_legacy_path,
|
|
|
|
|
},
|
|
|
|
|
ConfigEntry {
|
|
|
|
|
source: ConfigSource::User,
|
|
|
|
|
path: self.config_home.join("settings.json"),
|
|
|
|
|
},
|
|
|
|
|
ConfigEntry {
|
|
|
|
|
source: ConfigSource::Project,
|
2026-04-01 18:48:39 +09:00
|
|
|
path: self.cwd.join(".claw.json"),
|
2026-04-01 03:55:00 +00:00
|
|
|
},
|
|
|
|
|
ConfigEntry {
|
|
|
|
|
source: ConfigSource::Project,
|
2026-04-01 18:48:39 +09:00
|
|
|
path: self.cwd.join(".claw").join("settings.json"),
|
2026-04-01 03:55:00 +00:00
|
|
|
},
|
|
|
|
|
ConfigEntry {
|
|
|
|
|
source: ConfigSource::Local,
|
2026-04-01 18:48:39 +09:00
|
|
|
path: self.cwd.join(".claw").join("settings.local.json"),
|
2026-04-01 03:55:00 +00:00
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
|
|
|
|
|
let mut merged = BTreeMap::new();
|
|
|
|
|
let mut loaded_entries = Vec::new();
|
2026-06-04 18:31:58 +09:00
|
|
|
let mut mcp = McpConfigCollection::default();
|
2026-04-07 15:15:42 +09:00
|
|
|
let mut all_warnings = Vec::new();
|
2026-04-01 03:55:00 +00:00
|
|
|
|
|
|
|
|
for entry in self.discover() {
|
2026-04-07 15:15:42 +09:00
|
|
|
crate::config_validate::check_unsupported_format(&entry.path)?;
|
2026-06-03 21:46:47 +09:00
|
|
|
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
|
2026-04-01 03:55:00 +00:00
|
|
|
continue;
|
|
|
|
|
};
|
2026-04-07 15:15:42 +09:00
|
|
|
let validation = crate::config_validate::validate_config_file(
|
|
|
|
|
&parsed.object,
|
|
|
|
|
&parsed.source,
|
|
|
|
|
&entry.path,
|
|
|
|
|
);
|
|
|
|
|
if !validation.is_ok() {
|
|
|
|
|
let first_error = &validation.errors[0];
|
|
|
|
|
return Err(ConfigError::Parse(first_error.to_string()));
|
|
|
|
|
}
|
|
|
|
|
all_warnings.extend(validation.warnings);
|
|
|
|
|
validate_optional_hooks_config(&parsed.object, &entry.path)?;
|
2026-06-04 18:31:58 +09:00
|
|
|
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
|
2026-04-07 15:15:42 +09:00
|
|
|
deep_merge_objects(&mut merged, &parsed.object);
|
2026-04-01 03:55:00 +00:00
|
|
|
loaded_entries.push(entry);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:15:42 +09:00
|
|
|
for warning in &all_warnings {
|
2026-05-25 14:11:37 +09:00
|
|
|
emit_config_warning_once(&warning.to_string());
|
2026-04-07 15:15:42 +09:00
|
|
|
}
|
|
|
|
|
|
2026-06-04 18:31:58 +09:00
|
|
|
build_runtime_config(merged, loaded_entries, mcp)
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
2026-05-27 03:05:14 +09:00
|
|
|
|
|
|
|
|
/// Like [`load`] but also returns the list of validation warnings collected during
|
|
|
|
|
/// loading, without emitting them to stderr. Callers that want to surface warnings
|
|
|
|
|
/// through a structured channel (e.g. the JSON config envelope) should use this.
|
|
|
|
|
/// #773: enables JSON-mode callers to include `warnings` in their output envelope
|
|
|
|
|
/// instead of receiving unstructured text on stderr.
|
|
|
|
|
pub fn load_collecting_warnings(&self) -> Result<(RuntimeConfig, Vec<String>), ConfigError> {
|
|
|
|
|
let mut merged = BTreeMap::new();
|
|
|
|
|
let mut loaded_entries = Vec::new();
|
2026-06-04 18:31:58 +09:00
|
|
|
let mut mcp = McpConfigCollection::default();
|
2026-05-27 03:05:14 +09:00
|
|
|
let mut all_warnings: Vec<String> = Vec::new();
|
|
|
|
|
|
|
|
|
|
for entry in self.discover() {
|
|
|
|
|
crate::config_validate::check_unsupported_format(&entry.path)?;
|
2026-06-03 21:46:47 +09:00
|
|
|
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
|
2026-05-27 03:05:14 +09:00
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let validation = crate::config_validate::validate_config_file(
|
|
|
|
|
&parsed.object,
|
|
|
|
|
&parsed.source,
|
|
|
|
|
&entry.path,
|
|
|
|
|
);
|
|
|
|
|
if !validation.is_ok() {
|
|
|
|
|
let first_error = &validation.errors[0];
|
|
|
|
|
return Err(ConfigError::Parse(first_error.to_string()));
|
|
|
|
|
}
|
|
|
|
|
all_warnings.extend(validation.warnings.iter().map(|w| w.to_string()));
|
|
|
|
|
validate_optional_hooks_config(&parsed.object, &entry.path)?;
|
2026-06-04 18:31:58 +09:00
|
|
|
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
|
2026-05-27 03:05:14 +09:00
|
|
|
deep_merge_objects(&mut merged, &parsed.object);
|
|
|
|
|
loaded_entries.push(entry);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 18:31:58 +09:00
|
|
|
let config = build_runtime_config(merged, loaded_entries, mcp)?;
|
2026-06-03 21:46:47 +09:00
|
|
|
Ok((config, all_warnings))
|
|
|
|
|
}
|
2026-05-27 03:05:14 +09:00
|
|
|
|
2026-06-03 21:46:47 +09:00
|
|
|
/// Inspect every discovered config path and return per-file status details.
|
|
|
|
|
/// Unlike [`Self::load`], this is best-effort: invalid files are reported in
|
|
|
|
|
/// `files[]` and skipped from the merged runtime view so JSON config callers can
|
|
|
|
|
/// show the whole discovery picture without collapsing every unloaded path to
|
|
|
|
|
/// `loaded:false`.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn inspect_collecting_warnings(&self) -> ConfigInspection {
|
|
|
|
|
let mut merged = BTreeMap::new();
|
|
|
|
|
let mut loaded_entries = Vec::new();
|
2026-06-04 18:31:58 +09:00
|
|
|
let mut mcp = McpConfigCollection::default();
|
2026-06-03 21:46:47 +09:00
|
|
|
let mut warnings = Vec::new();
|
|
|
|
|
let mut files = Vec::new();
|
|
|
|
|
let mut load_error = None;
|
|
|
|
|
|
2026-06-03 23:47:27 +09:00
|
|
|
for (index, entry) in self.discover().into_iter().enumerate() {
|
|
|
|
|
let precedence_rank = index + 1;
|
2026-06-03 21:46:47 +09:00
|
|
|
if let Err(error) = crate::config_validate::check_unsupported_format(&entry.path) {
|
|
|
|
|
let detail = error.to_string();
|
|
|
|
|
load_error.get_or_insert_with(|| detail.clone());
|
|
|
|
|
files.push(ConfigFileReport::load_error(
|
|
|
|
|
entry,
|
2026-06-03 23:47:27 +09:00
|
|
|
precedence_rank,
|
2026-06-03 21:46:47 +09:00
|
|
|
"unsupported_format",
|
|
|
|
|
detail,
|
|
|
|
|
));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-27 03:05:14 +09:00
|
|
|
|
2026-06-03 21:46:47 +09:00
|
|
|
let parsed = match read_optional_json_object(&entry.path) {
|
|
|
|
|
Ok(OptionalConfigFile::Loaded(parsed)) => parsed,
|
|
|
|
|
Ok(OptionalConfigFile::NotFound) => {
|
2026-06-03 23:47:27 +09:00
|
|
|
files.push(ConfigFileReport::not_found(entry, precedence_rank));
|
2026-06-03 21:46:47 +09:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
Ok(OptionalConfigFile::Skipped { reason, detail }) => {
|
2026-06-03 23:47:27 +09:00
|
|
|
files.push(ConfigFileReport::skipped(
|
|
|
|
|
entry,
|
|
|
|
|
precedence_rank,
|
|
|
|
|
reason,
|
|
|
|
|
detail,
|
|
|
|
|
));
|
2026-06-03 21:46:47 +09:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
Err(error) => {
|
|
|
|
|
let reason = config_error_reason(&error).to_string();
|
|
|
|
|
let detail = error.to_string();
|
|
|
|
|
load_error.get_or_insert_with(|| detail.clone());
|
2026-06-03 23:47:27 +09:00
|
|
|
files.push(ConfigFileReport::load_error(
|
|
|
|
|
entry,
|
|
|
|
|
precedence_rank,
|
|
|
|
|
reason,
|
|
|
|
|
detail,
|
|
|
|
|
));
|
2026-06-03 21:46:47 +09:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let validation = crate::config_validate::validate_config_file(
|
|
|
|
|
&parsed.object,
|
|
|
|
|
&parsed.source,
|
|
|
|
|
&entry.path,
|
|
|
|
|
);
|
|
|
|
|
if !validation.is_ok() {
|
|
|
|
|
let detail = validation.errors[0].to_string();
|
|
|
|
|
load_error.get_or_insert_with(|| detail.clone());
|
|
|
|
|
files.push(ConfigFileReport::load_error(
|
|
|
|
|
entry,
|
2026-06-03 23:47:27 +09:00
|
|
|
precedence_rank,
|
2026-06-03 21:46:47 +09:00
|
|
|
"validation_error",
|
|
|
|
|
detail,
|
|
|
|
|
));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
warnings.extend(
|
|
|
|
|
validation
|
|
|
|
|
.warnings
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|warning| warning.to_string()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if let Err(error) = validate_optional_hooks_config(&parsed.object, &entry.path) {
|
|
|
|
|
let detail = error.to_string();
|
|
|
|
|
load_error.get_or_insert_with(|| detail.clone());
|
|
|
|
|
files.push(ConfigFileReport::load_error(
|
|
|
|
|
entry,
|
2026-06-03 23:47:27 +09:00
|
|
|
precedence_rank,
|
2026-06-03 21:46:47 +09:00
|
|
|
"validation_error",
|
|
|
|
|
detail,
|
|
|
|
|
));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Err(error) =
|
2026-06-04 18:31:58 +09:00
|
|
|
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)
|
2026-06-03 21:46:47 +09:00
|
|
|
{
|
|
|
|
|
let detail = error.to_string();
|
|
|
|
|
load_error.get_or_insert_with(|| detail.clone());
|
2026-06-03 23:47:27 +09:00
|
|
|
files.push(ConfigFileReport::load_error(
|
|
|
|
|
entry,
|
|
|
|
|
precedence_rank,
|
|
|
|
|
"parse_error",
|
|
|
|
|
detail,
|
|
|
|
|
));
|
2026-06-03 21:46:47 +09:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 23:47:27 +09:00
|
|
|
let key_paths = collect_config_key_paths(&parsed.object);
|
2026-06-03 21:46:47 +09:00
|
|
|
deep_merge_objects(&mut merged, &parsed.object);
|
|
|
|
|
loaded_entries.push(entry.clone());
|
2026-06-03 23:47:27 +09:00
|
|
|
files.push(ConfigFileReport::loaded(entry, precedence_rank, key_paths));
|
2026-06-03 21:46:47 +09:00
|
|
|
}
|
|
|
|
|
|
2026-06-03 23:47:27 +09:00
|
|
|
annotate_config_file_precedence(&mut files);
|
|
|
|
|
|
2026-06-04 18:31:58 +09:00
|
|
|
let runtime_config = match build_runtime_config(merged, loaded_entries, mcp) {
|
2026-06-03 21:46:47 +09:00
|
|
|
Ok(config) => Some(config),
|
|
|
|
|
Err(error) => {
|
|
|
|
|
load_error.get_or_insert_with(|| error.to_string());
|
|
|
|
|
None
|
|
|
|
|
}
|
2026-05-27 03:05:14 +09:00
|
|
|
};
|
2026-06-03 21:46:47 +09:00
|
|
|
|
|
|
|
|
ConfigInspection {
|
|
|
|
|
files,
|
|
|
|
|
runtime_config,
|
|
|
|
|
warnings,
|
|
|
|
|
load_error,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ConfigFileReport {
|
2026-06-03 23:47:27 +09:00
|
|
|
fn loaded(entry: ConfigEntry, precedence_rank: usize, key_paths: Vec<String>) -> Self {
|
2026-06-03 21:46:47 +09:00
|
|
|
Self {
|
|
|
|
|
entry,
|
|
|
|
|
loaded: true,
|
|
|
|
|
status: ConfigFileStatus::Loaded,
|
|
|
|
|
reason: None,
|
|
|
|
|
detail: None,
|
2026-06-03 23:47:27 +09:00
|
|
|
precedence_rank,
|
|
|
|
|
wins_for_keys: Vec::new(),
|
|
|
|
|
shadowed_keys: Vec::new(),
|
|
|
|
|
key_paths,
|
2026-06-03 21:46:47 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 23:47:27 +09:00
|
|
|
fn not_found(entry: ConfigEntry, precedence_rank: usize) -> Self {
|
2026-06-03 21:46:47 +09:00
|
|
|
Self {
|
|
|
|
|
entry,
|
|
|
|
|
loaded: false,
|
|
|
|
|
status: ConfigFileStatus::NotFound,
|
|
|
|
|
reason: Some("not_found".to_string()),
|
|
|
|
|
detail: None,
|
2026-06-03 23:47:27 +09:00
|
|
|
precedence_rank,
|
|
|
|
|
wins_for_keys: Vec::new(),
|
|
|
|
|
shadowed_keys: Vec::new(),
|
|
|
|
|
key_paths: Vec::new(),
|
2026-06-03 21:46:47 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 23:47:27 +09:00
|
|
|
fn skipped(
|
|
|
|
|
entry: ConfigEntry,
|
|
|
|
|
precedence_rank: usize,
|
|
|
|
|
reason: String,
|
|
|
|
|
detail: Option<String>,
|
|
|
|
|
) -> Self {
|
2026-06-03 21:46:47 +09:00
|
|
|
Self {
|
|
|
|
|
entry,
|
|
|
|
|
loaded: false,
|
|
|
|
|
status: ConfigFileStatus::Skipped,
|
|
|
|
|
reason: Some(reason),
|
|
|
|
|
detail,
|
2026-06-03 23:47:27 +09:00
|
|
|
precedence_rank,
|
|
|
|
|
wins_for_keys: Vec::new(),
|
|
|
|
|
shadowed_keys: Vec::new(),
|
|
|
|
|
key_paths: Vec::new(),
|
2026-06-03 21:46:47 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 23:47:27 +09:00
|
|
|
fn load_error(
|
|
|
|
|
entry: ConfigEntry,
|
|
|
|
|
precedence_rank: usize,
|
|
|
|
|
reason: impl Into<String>,
|
|
|
|
|
detail: String,
|
|
|
|
|
) -> Self {
|
2026-06-03 21:46:47 +09:00
|
|
|
Self {
|
|
|
|
|
entry,
|
|
|
|
|
loaded: false,
|
|
|
|
|
status: ConfigFileStatus::LoadError,
|
|
|
|
|
reason: Some(reason.into()),
|
|
|
|
|
detail: Some(detail),
|
2026-06-03 23:47:27 +09:00
|
|
|
precedence_rank,
|
|
|
|
|
wins_for_keys: Vec::new(),
|
|
|
|
|
shadowed_keys: Vec::new(),
|
|
|
|
|
key_paths: Vec::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn annotate_config_file_precedence(files: &mut [ConfigFileReport]) {
|
|
|
|
|
let mut winning_file_by_key = BTreeMap::new();
|
|
|
|
|
for (index, file) in files.iter().enumerate() {
|
|
|
|
|
if !file.loaded {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
for key in &file.key_paths {
|
|
|
|
|
winning_file_by_key.insert(key.clone(), index);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (index, file) in files.iter_mut().enumerate() {
|
|
|
|
|
if !file.loaded {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let mut wins_for_keys = Vec::new();
|
|
|
|
|
let mut shadowed_keys = Vec::new();
|
|
|
|
|
for key in &file.key_paths {
|
|
|
|
|
if winning_file_by_key.get(key).copied() == Some(index) {
|
|
|
|
|
wins_for_keys.push(key.clone());
|
|
|
|
|
} else {
|
|
|
|
|
shadowed_keys.push(key.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
file.wins_for_keys = wins_for_keys;
|
|
|
|
|
file.shadowed_keys = shadowed_keys;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_config_key_paths(object: &BTreeMap<String, JsonValue>) -> Vec<String> {
|
|
|
|
|
let mut keys = Vec::new();
|
|
|
|
|
for (key, value) in object {
|
|
|
|
|
collect_config_key_paths_for_value(key, value, &mut keys);
|
|
|
|
|
}
|
|
|
|
|
keys
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_config_key_paths_for_value(prefix: &str, value: &JsonValue, keys: &mut Vec<String>) {
|
|
|
|
|
match value {
|
|
|
|
|
JsonValue::Object(object) if !object.is_empty() => {
|
|
|
|
|
for (key, nested) in object {
|
|
|
|
|
collect_config_key_paths_for_value(&format!("{prefix}.{key}"), nested, keys);
|
|
|
|
|
}
|
2026-06-03 21:46:47 +09:00
|
|
|
}
|
2026-06-03 23:47:27 +09:00
|
|
|
_ => keys.push(prefix.to_string()),
|
2026-06-03 21:46:47 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_runtime_config(
|
|
|
|
|
merged: BTreeMap<String, JsonValue>,
|
|
|
|
|
loaded_entries: Vec<ConfigEntry>,
|
2026-06-04 18:31:58 +09:00
|
|
|
mcp: McpConfigCollection,
|
2026-06-03 21:46:47 +09:00
|
|
|
) -> Result<RuntimeConfig, ConfigError> {
|
|
|
|
|
let merged_value = JsonValue::Object(merged.clone());
|
|
|
|
|
|
|
|
|
|
let feature_config = RuntimeFeatureConfig {
|
|
|
|
|
hooks: parse_optional_hooks_config(&merged_value)?,
|
|
|
|
|
plugins: parse_optional_plugin_config(&merged_value)?,
|
2026-06-04 18:31:58 +09:00
|
|
|
mcp,
|
2026-06-03 21:46:47 +09:00
|
|
|
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
|
|
|
|
|
model: parse_optional_model(&merged_value),
|
|
|
|
|
aliases: parse_optional_aliases(&merged_value)?,
|
|
|
|
|
permission_mode: parse_optional_permission_mode(&merged_value)?,
|
|
|
|
|
permission_rules: parse_optional_permission_rules(&merged_value)?,
|
|
|
|
|
sandbox: parse_optional_sandbox_config(&merged_value)?,
|
|
|
|
|
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
|
|
|
|
|
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
|
|
|
|
|
rules_import: parse_optional_rules_import(&merged_value)?,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(RuntimeConfig {
|
|
|
|
|
merged,
|
|
|
|
|
loaded_entries,
|
|
|
|
|
feature_config,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn config_error_reason(error: &ConfigError) -> &'static str {
|
|
|
|
|
match error {
|
|
|
|
|
ConfigError::Io(io_error) if io_error.kind() == std::io::ErrorKind::PermissionDenied => {
|
|
|
|
|
"permission_denied"
|
|
|
|
|
}
|
|
|
|
|
ConfigError::Io(_) => "io_error",
|
|
|
|
|
ConfigError::Parse(_) => "parse_error",
|
2026-05-27 03:05:14 +09:00
|
|
|
}
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RuntimeConfig {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn empty() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
merged: BTreeMap::new(),
|
|
|
|
|
loaded_entries: Vec::new(),
|
|
|
|
|
feature_config: RuntimeFeatureConfig::default(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
|
|
|
|
|
&self.merged
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_entries(&self) -> &[ConfigEntry] {
|
|
|
|
|
&self.loaded_entries
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn get(&self, key: &str) -> Option<&JsonValue> {
|
|
|
|
|
self.merged.get(key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn as_json(&self) -> JsonValue {
|
|
|
|
|
JsonValue::Object(self.merged.clone())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn feature_config(&self) -> &RuntimeFeatureConfig {
|
|
|
|
|
&self.feature_config
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn mcp(&self) -> &McpConfigCollection {
|
|
|
|
|
&self.feature_config.mcp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn hooks(&self) -> &RuntimeHookConfig {
|
|
|
|
|
&self.feature_config.hooks
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:30:28 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn plugins(&self) -> &RuntimePluginConfig {
|
|
|
|
|
&self.feature_config.plugins
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
|
|
|
|
self.feature_config.oauth.as_ref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn model(&self) -> Option<&str> {
|
|
|
|
|
self.feature_config.model.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 14:51:12 +09:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn aliases(&self) -> &BTreeMap<String, String> {
|
|
|
|
|
&self.feature_config.aliases
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
|
|
|
|
|
self.feature_config.permission_mode
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:30:25 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
|
|
|
|
|
&self.feature_config.permission_rules
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn sandbox(&self) -> &SandboxConfig {
|
|
|
|
|
&self.feature_config.sandbox
|
|
|
|
|
}
|
2026-04-07 14:51:12 +09:00
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn provider_fallbacks(&self) -> &ProviderFallbackConfig {
|
|
|
|
|
&self.feature_config.provider_fallbacks
|
|
|
|
|
}
|
2026-04-08 02:35:19 +09:00
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn trusted_roots(&self) -> &[String] {
|
|
|
|
|
&self.feature_config.trusted_roots
|
|
|
|
|
}
|
2026-05-14 17:40:28 +09:00
|
|
|
|
2026-06-03 21:01:48 +09:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn rules_import(&self) -> &RulesImportConfig {
|
|
|
|
|
&self.feature_config.rules_import
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 17:40:28 +09:00
|
|
|
/// Merge config-level default trusted roots with per-call roots.
|
|
|
|
|
///
|
|
|
|
|
/// Config roots are defaults and are kept first; per-call roots extend the
|
|
|
|
|
/// allowlist for a specific worker/session creation request. Duplicates are
|
|
|
|
|
/// removed without reordering the first occurrence so evidence remains
|
|
|
|
|
/// deterministic while avoiding repeated trust checks.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
|
|
|
|
|
merge_trusted_roots(self.trusted_roots(), per_call_roots)
|
|
|
|
|
}
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RuntimeFeatureConfig {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
|
|
|
|
|
self.hooks = hooks;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:30:28 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self {
|
|
|
|
|
self.plugins = plugins;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn hooks(&self) -> &RuntimeHookConfig {
|
|
|
|
|
&self.hooks
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:30:28 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn plugins(&self) -> &RuntimePluginConfig {
|
|
|
|
|
&self.plugins
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn mcp(&self) -> &McpConfigCollection {
|
|
|
|
|
&self.mcp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
|
|
|
|
self.oauth.as_ref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn model(&self) -> Option<&str> {
|
|
|
|
|
self.model.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 14:51:12 +09:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn aliases(&self) -> &BTreeMap<String, String> {
|
|
|
|
|
&self.aliases
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
|
|
|
|
|
self.permission_mode
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:20:16 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
|
|
|
|
|
&self.permission_rules
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn sandbox(&self) -> &SandboxConfig {
|
|
|
|
|
&self.sandbox
|
|
|
|
|
}
|
2026-04-07 14:51:12 +09:00
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn provider_fallbacks(&self) -> &ProviderFallbackConfig {
|
|
|
|
|
&self.provider_fallbacks
|
|
|
|
|
}
|
2026-04-08 02:35:19 +09:00
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn trusted_roots(&self) -> &[String] {
|
|
|
|
|
&self.trusted_roots
|
|
|
|
|
}
|
2026-05-14 17:40:28 +09:00
|
|
|
|
2026-06-03 21:01:48 +09:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn rules_import(&self) -> &RulesImportConfig {
|
|
|
|
|
&self.rules_import
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 17:40:28 +09:00
|
|
|
/// Merge this config's default trusted roots with per-call roots.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
|
|
|
|
|
merge_trusted_roots(self.trusted_roots(), per_call_roots)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Vec<String> {
|
|
|
|
|
let mut merged = Vec::with_capacity(config_roots.len() + per_call_roots.len());
|
|
|
|
|
for root in config_roots.iter().chain(per_call_roots.iter()) {
|
|
|
|
|
if !merged.contains(root) {
|
|
|
|
|
merged.push(root.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
merged
|
2026-04-07 14:51:12 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ProviderFallbackConfig {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn new(primary: Option<String>, fallbacks: Vec<String>) -> Self {
|
|
|
|
|
Self { primary, fallbacks }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn primary(&self) -> Option<&str> {
|
|
|
|
|
self.primary.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn fallbacks(&self) -> &[String] {
|
|
|
|
|
&self.fallbacks
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
|
|
|
self.fallbacks.is_empty()
|
|
|
|
|
}
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:30:28 +00:00
|
|
|
impl RuntimePluginConfig {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn enabled_plugins(&self) -> &BTreeMap<String, bool> {
|
|
|
|
|
&self.enabled_plugins
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn external_directories(&self) -> &[String] {
|
|
|
|
|
&self.external_directories
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn install_root(&self) -> Option<&str> {
|
|
|
|
|
self.install_root.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn registry_path(&self) -> Option<&str> {
|
|
|
|
|
self.registry_path.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn bundled_root(&self) -> Option<&str> {
|
|
|
|
|
self.bundled_root.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:52:11 +09:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn max_output_tokens(&self) -> Option<u32> {
|
|
|
|
|
self.max_output_tokens
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn set_max_output_tokens(&mut self, max_output_tokens: Option<u32>) {
|
|
|
|
|
self.max_output_tokens = max_output_tokens;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:30:28 +00:00
|
|
|
pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) {
|
|
|
|
|
self.enabled_plugins.insert(plugin_id, enabled);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool {
|
|
|
|
|
self.enabled_plugins
|
|
|
|
|
.get(plugin_id)
|
|
|
|
|
.copied()
|
|
|
|
|
.unwrap_or(default_enabled)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
2026-04-04 15:23:29 +00:00
|
|
|
/// Returns the default per-user config directory used by the runtime.
|
2026-04-01 04:30:28 +00:00
|
|
|
pub fn default_config_home() -> PathBuf {
|
2026-04-01 18:48:39 +09:00
|
|
|
std::env::var_os("CLAW_CONFIG_HOME")
|
2026-04-01 04:30:28 +00:00
|
|
|
.map(PathBuf::from)
|
2026-04-01 18:48:39 +09:00
|
|
|
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claw")))
|
|
|
|
|
.unwrap_or_else(|| PathBuf::from(".claw"))
|
2026-04-01 04:30:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-24 21:24:20 -05:00
|
|
|
/// Save provider settings to the user-level `~/.claw/settings.json`.
|
|
|
|
|
/// Creates the file and directory if they don't exist. Sets file permissions
|
|
|
|
|
/// to `0o600` (owner read/write only) to protect stored API keys.
|
|
|
|
|
pub fn save_user_provider_settings(
|
|
|
|
|
kind: &str,
|
|
|
|
|
api_key: &str,
|
|
|
|
|
base_url: Option<&str>,
|
|
|
|
|
model: Option<&str>,
|
|
|
|
|
) -> Result<(), ConfigError> {
|
|
|
|
|
let config_home = default_config_home();
|
|
|
|
|
fs::create_dir_all(&config_home).map_err(ConfigError::Io)?;
|
|
|
|
|
let settings_path = config_home.join("settings.json");
|
|
|
|
|
|
|
|
|
|
let mut root = read_settings_root(&settings_path);
|
|
|
|
|
|
|
|
|
|
let mut provider = serde_json::Map::new();
|
2026-05-25 11:51:44 +09:00
|
|
|
provider.insert(
|
|
|
|
|
"kind".to_string(),
|
|
|
|
|
serde_json::Value::String(kind.to_string()),
|
|
|
|
|
);
|
|
|
|
|
provider.insert(
|
|
|
|
|
"apiKey".to_string(),
|
|
|
|
|
serde_json::Value::String(api_key.to_string()),
|
|
|
|
|
);
|
2026-05-24 21:24:20 -05:00
|
|
|
if let Some(base_url) = base_url {
|
2026-05-25 11:51:44 +09:00
|
|
|
provider.insert(
|
|
|
|
|
"baseUrl".to_string(),
|
|
|
|
|
serde_json::Value::String(base_url.to_string()),
|
|
|
|
|
);
|
2026-05-24 21:24:20 -05:00
|
|
|
} else {
|
|
|
|
|
provider.remove("baseUrl");
|
|
|
|
|
}
|
|
|
|
|
root.insert("provider".to_string(), serde_json::Value::Object(provider));
|
|
|
|
|
if let Some(model) = model {
|
2026-05-25 11:51:44 +09:00
|
|
|
root.insert(
|
|
|
|
|
"model".to_string(),
|
|
|
|
|
serde_json::Value::String(model.to_string()),
|
|
|
|
|
);
|
2026-05-24 21:24:20 -05:00
|
|
|
} else {
|
|
|
|
|
root.remove("model");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
write_settings_root(&settings_path, &root)?;
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
let perms = std::fs::Permissions::from_mode(0o600);
|
|
|
|
|
fs::set_permissions(&settings_path, perms).map_err(ConfigError::Io)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Remove the `provider` section from the user-level `~/.claw/settings.json`.
|
|
|
|
|
pub fn clear_user_provider_settings() -> Result<(), ConfigError> {
|
|
|
|
|
let config_home = default_config_home();
|
|
|
|
|
let settings_path = config_home.join("settings.json");
|
|
|
|
|
|
|
|
|
|
if !settings_path.exists() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut root = read_settings_root(&settings_path);
|
|
|
|
|
if root.remove("provider").is_none() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
root.remove("model");
|
|
|
|
|
|
|
|
|
|
write_settings_root(&settings_path, &root)?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_settings_root(path: &Path) -> serde_json::Map<String, serde_json::Value> {
|
|
|
|
|
match fs::read_to_string(path) {
|
|
|
|
|
Ok(contents) if !contents.trim().is_empty() => {
|
|
|
|
|
serde_json::from_str::<serde_json::Value>(&contents)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|v| v.as_object().cloned())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
_ => serde_json::Map::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_settings_root(
|
|
|
|
|
path: &Path,
|
|
|
|
|
root: &serde_json::Map<String, serde_json::Value>,
|
|
|
|
|
) -> Result<(), ConfigError> {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
fs::create_dir_all(parent).map_err(ConfigError::Io)?;
|
|
|
|
|
}
|
|
|
|
|
let rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone()))
|
|
|
|
|
.map_err(|e| ConfigError::Parse(e.to_string()))?;
|
|
|
|
|
fs::write(path, format!("{rendered}\n")).map_err(ConfigError::Io)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:23:00 +09:00
|
|
|
impl RuntimeHookCommand {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn new(command: impl Into<String>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
command: command.into(),
|
|
|
|
|
matcher: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn with_matcher(command: impl Into<String>, matcher: Option<String>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
command: command.into(),
|
|
|
|
|
matcher: matcher.and_then(|value| {
|
|
|
|
|
let trimmed = value.trim();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(trimmed.to_string())
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn command(&self) -> &str {
|
|
|
|
|
&self.command
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn matcher(&self) -> Option<&str> {
|
|
|
|
|
self.matcher.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn matches_tool(&self, tool_name: &str) -> bool {
|
|
|
|
|
self.matcher
|
|
|
|
|
.as_deref()
|
|
|
|
|
.is_none_or(|matcher| hook_matcher_matches(matcher, tool_name))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
impl RuntimeHookConfig {
|
|
|
|
|
#[must_use]
|
2026-04-01 04:20:16 +00:00
|
|
|
pub fn new(
|
|
|
|
|
pre_tool_use: Vec<String>,
|
|
|
|
|
post_tool_use: Vec<String>,
|
|
|
|
|
post_tool_use_failure: Vec<String>,
|
2026-06-03 21:23:00 +09:00
|
|
|
) -> Self {
|
|
|
|
|
Self::from_hook_commands(
|
|
|
|
|
pre_tool_use
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(RuntimeHookCommand::new)
|
|
|
|
|
.collect(),
|
|
|
|
|
post_tool_use
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(RuntimeHookCommand::new)
|
|
|
|
|
.collect(),
|
|
|
|
|
post_tool_use_failure
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(RuntimeHookCommand::new)
|
|
|
|
|
.collect(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn from_hook_commands(
|
|
|
|
|
pre_tool_use: Vec<RuntimeHookCommand>,
|
|
|
|
|
post_tool_use: Vec<RuntimeHookCommand>,
|
|
|
|
|
post_tool_use_failure: Vec<RuntimeHookCommand>,
|
2026-04-01 04:20:16 +00:00
|
|
|
) -> Self {
|
2026-04-01 03:55:00 +00:00
|
|
|
Self {
|
|
|
|
|
pre_tool_use,
|
|
|
|
|
post_tool_use,
|
2026-04-01 04:20:16 +00:00
|
|
|
post_tool_use_failure,
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
2026-06-03 21:23:00 +09:00
|
|
|
pub fn pre_tool_use(&self) -> Vec<String> {
|
|
|
|
|
hook_commands(&self.pre_tool_use)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn pre_tool_use_entries(&self) -> &[RuntimeHookCommand] {
|
2026-04-01 03:55:00 +00:00
|
|
|
&self.pre_tool_use
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
2026-06-03 21:23:00 +09:00
|
|
|
pub fn post_tool_use(&self) -> Vec<String> {
|
|
|
|
|
hook_commands(&self.post_tool_use)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn post_tool_use_entries(&self) -> &[RuntimeHookCommand] {
|
2026-04-01 03:55:00 +00:00
|
|
|
&self.post_tool_use
|
|
|
|
|
}
|
2026-04-01 04:30:28 +00:00
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn merged(&self, other: &Self) -> Self {
|
|
|
|
|
let mut merged = self.clone();
|
|
|
|
|
merged.extend(other);
|
|
|
|
|
merged
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn extend(&mut self, other: &Self) {
|
2026-06-03 21:23:00 +09:00
|
|
|
extend_unique_hook_commands(&mut self.pre_tool_use, other.pre_tool_use_entries());
|
|
|
|
|
extend_unique_hook_commands(&mut self.post_tool_use, other.post_tool_use_entries());
|
|
|
|
|
extend_unique_hook_commands(
|
2026-04-02 11:05:03 +09:00
|
|
|
&mut self.post_tool_use_failure,
|
2026-06-03 21:23:00 +09:00
|
|
|
other.post_tool_use_failure_entries(),
|
2026-04-02 11:05:03 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:20:16 +00:00
|
|
|
#[must_use]
|
2026-06-03 21:23:00 +09:00
|
|
|
pub fn post_tool_use_failure(&self) -> Vec<String> {
|
|
|
|
|
hook_commands(&self.post_tool_use_failure)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn post_tool_use_failure_entries(&self) -> &[RuntimeHookCommand] {
|
2026-04-01 04:20:16 +00:00
|
|
|
&self.post_tool_use_failure
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:23:00 +09:00
|
|
|
fn hook_commands(commands: &[RuntimeHookCommand]) -> Vec<String> {
|
|
|
|
|
commands.iter().map(|entry| entry.command.clone()).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn hook_matcher_matches(matcher: &str, tool_name: &str) -> bool {
|
|
|
|
|
matcher
|
|
|
|
|
.split([',', '|'])
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|part| !part.is_empty())
|
|
|
|
|
.any(|part| {
|
|
|
|
|
part == "*" || part.eq_ignore_ascii_case(tool_name) || wildcard_match(part, tool_name)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn wildcard_match(pattern: &str, value: &str) -> bool {
|
|
|
|
|
if !pattern.contains('*') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
let pattern = pattern.to_ascii_lowercase();
|
|
|
|
|
let value = value.to_ascii_lowercase();
|
|
|
|
|
let parts = pattern.split('*').collect::<Vec<_>>();
|
|
|
|
|
let mut remainder = value.as_str();
|
|
|
|
|
let starts_with_wildcard = pattern.starts_with('*');
|
|
|
|
|
let ends_with_wildcard = pattern.ends_with('*');
|
|
|
|
|
|
|
|
|
|
if let Some(first) = parts.first().filter(|part| !part.is_empty()) {
|
|
|
|
|
if !starts_with_wildcard && !remainder.starts_with(first) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if let Some(index) = remainder.find(first) {
|
|
|
|
|
remainder = &remainder[index + first.len()..];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for part in parts.iter().skip(1).filter(|part| !part.is_empty()) {
|
|
|
|
|
let Some(index) = remainder.find(part) else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
remainder = &remainder[index + part.len()..];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ends_with_wildcard
|
|
|
|
|
|| parts
|
|
|
|
|
.last()
|
|
|
|
|
.is_none_or(|last| last.is_empty() || remainder.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:20:16 +00:00
|
|
|
impl RuntimePermissionRuleConfig {
|
|
|
|
|
#[must_use]
|
2026-05-25 12:01:09 +09:00
|
|
|
pub fn new(
|
|
|
|
|
allow: Vec<String>,
|
|
|
|
|
deny: Vec<String>,
|
|
|
|
|
ask: Vec<String>,
|
|
|
|
|
denied_tools: Vec<String>,
|
|
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
allow,
|
|
|
|
|
deny,
|
|
|
|
|
ask,
|
|
|
|
|
denied_tools,
|
|
|
|
|
}
|
2026-04-01 04:20:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn allow(&self) -> &[String] {
|
|
|
|
|
&self.allow
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn deny(&self) -> &[String] {
|
|
|
|
|
&self.deny
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn ask(&self) -> &[String] {
|
|
|
|
|
&self.ask
|
2026-04-01 04:30:28 +00:00
|
|
|
}
|
2026-05-25 12:01:09 +09:00
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn denied_tools(&self) -> &[String] {
|
|
|
|
|
&self.denied_tools
|
|
|
|
|
}
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl McpConfigCollection {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
|
|
|
|
|
&self.servers
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 18:31:58 +09:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn invalid_servers(&self) -> &[McpInvalidServerConfig] {
|
|
|
|
|
&self.invalid_servers
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn total_configured(&self) -> usize {
|
|
|
|
|
self.total_configured
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn valid_count(&self) -> usize {
|
|
|
|
|
self.servers.len()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn invalid_count(&self) -> usize {
|
|
|
|
|
self.invalid_servers.len()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn has_invalid_servers(&self) -> bool {
|
|
|
|
|
!self.invalid_servers.is_empty()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
|
|
|
|
|
self.servers.get(name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ScopedMcpServerConfig {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn transport(&self) -> McpTransport {
|
|
|
|
|
self.config.transport()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl McpServerConfig {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn transport(&self) -> McpTransport {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Stdio(_) => McpTransport::Stdio,
|
|
|
|
|
Self::Sse(_) => McpTransport::Sse,
|
|
|
|
|
Self::Http(_) => McpTransport::Http,
|
|
|
|
|
Self::Ws(_) => McpTransport::Ws,
|
|
|
|
|
Self::Sdk(_) => McpTransport::Sdk,
|
2026-04-01 18:48:39 +09:00
|
|
|
Self::ManagedProxy(_) => McpTransport::ManagedProxy,
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:15:42 +09:00
|
|
|
/// Parsed JSON object paired with its raw source text for validation.
|
|
|
|
|
struct ParsedConfigFile {
|
|
|
|
|
object: BTreeMap<String, JsonValue>,
|
|
|
|
|
source: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:46:47 +09:00
|
|
|
enum OptionalConfigFile {
|
|
|
|
|
Loaded(ParsedConfigFile),
|
|
|
|
|
NotFound,
|
|
|
|
|
Skipped {
|
|
|
|
|
reason: String,
|
|
|
|
|
detail: Option<String>,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_optional_json_object(path: &Path) -> Result<OptionalConfigFile, ConfigError> {
|
2026-04-01 18:48:39 +09:00
|
|
|
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claw.json");
|
2026-04-01 03:55:00 +00:00
|
|
|
let contents = match fs::read_to_string(path) {
|
|
|
|
|
Ok(contents) => contents,
|
2026-06-03 21:46:47 +09:00
|
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
|
|
|
return Ok(OptionalConfigFile::NotFound);
|
|
|
|
|
}
|
2026-04-01 03:55:00 +00:00
|
|
|
Err(error) => return Err(ConfigError::Io(error)),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if contents.trim().is_empty() {
|
2026-06-03 21:46:47 +09:00
|
|
|
return Ok(OptionalConfigFile::Loaded(ParsedConfigFile {
|
2026-04-07 15:15:42 +09:00
|
|
|
object: BTreeMap::new(),
|
|
|
|
|
source: contents,
|
|
|
|
|
}));
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let parsed = match JsonValue::parse(&contents) {
|
|
|
|
|
Ok(parsed) => parsed,
|
2026-06-03 21:46:47 +09:00
|
|
|
Err(error) if is_legacy_config => {
|
|
|
|
|
return Ok(OptionalConfigFile::Skipped {
|
|
|
|
|
reason: "legacy_invalid_json".to_string(),
|
|
|
|
|
detail: Some(format!("{}: {error}", path.display())),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-01 03:55:00 +00:00
|
|
|
Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
|
|
|
|
|
};
|
|
|
|
|
let Some(object) = parsed.as_object() else {
|
|
|
|
|
if is_legacy_config {
|
2026-06-03 21:46:47 +09:00
|
|
|
return Ok(OptionalConfigFile::Skipped {
|
|
|
|
|
reason: "legacy_non_object".to_string(),
|
|
|
|
|
detail: Some(format!(
|
|
|
|
|
"{}: top-level legacy settings value is not a JSON object",
|
|
|
|
|
path.display()
|
|
|
|
|
)),
|
|
|
|
|
});
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{}: top-level settings value must be a JSON object",
|
|
|
|
|
path.display()
|
|
|
|
|
)));
|
|
|
|
|
};
|
2026-06-03 21:46:47 +09:00
|
|
|
Ok(OptionalConfigFile::Loaded(ParsedConfigFile {
|
2026-04-07 15:15:42 +09:00
|
|
|
object: object.clone(),
|
|
|
|
|
source: contents,
|
|
|
|
|
}))
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn merge_mcp_servers(
|
2026-06-04 18:31:58 +09:00
|
|
|
target: &mut McpConfigCollection,
|
2026-04-01 03:55:00 +00:00
|
|
|
source: ConfigSource,
|
|
|
|
|
root: &BTreeMap<String, JsonValue>,
|
|
|
|
|
path: &Path,
|
|
|
|
|
) -> Result<(), ConfigError> {
|
|
|
|
|
let Some(mcp_servers) = root.get("mcpServers") else {
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
|
2026-06-04 18:31:58 +09:00
|
|
|
target.total_configured += servers.len();
|
2026-04-01 03:55:00 +00:00
|
|
|
for (name, value) in servers {
|
2026-06-04 18:31:58 +09:00
|
|
|
let context = format!("{}: mcpServers.{name}", path.display());
|
|
|
|
|
let Ok(object) = expect_object(value, &context) else {
|
|
|
|
|
let error = expect_object(value, &context).expect_err("object parse must fail");
|
|
|
|
|
target.servers.remove(name);
|
|
|
|
|
target
|
|
|
|
|
.invalid_servers
|
|
|
|
|
.push(mcp_invalid_server(name, source, path, &context, &error));
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let required = match optional_bool(object, "required", &context) {
|
|
|
|
|
Ok(required) => required.unwrap_or(false),
|
|
|
|
|
Err(error) => {
|
|
|
|
|
target.servers.remove(name);
|
|
|
|
|
target
|
|
|
|
|
.invalid_servers
|
|
|
|
|
.push(mcp_invalid_server(name, source, path, &context, &error));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if let Err(error) = validate_mcp_server_keys(name, object, &context) {
|
|
|
|
|
target.servers.remove(name);
|
|
|
|
|
target
|
|
|
|
|
.invalid_servers
|
|
|
|
|
.push(mcp_invalid_server(name, source, path, &context, &error));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let parsed = match parse_mcp_server_config(name, value, &context) {
|
|
|
|
|
Ok(parsed) => parsed,
|
|
|
|
|
Err(error) => {
|
|
|
|
|
target.servers.remove(name);
|
|
|
|
|
target
|
|
|
|
|
.invalid_servers
|
|
|
|
|
.push(mcp_invalid_server(name, source, path, &context, &error));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
target.servers.insert(
|
2026-04-01 03:55:00 +00:00
|
|
|
name.clone(),
|
|
|
|
|
ScopedMcpServerConfig {
|
2026-06-04 18:31:58 +09:00
|
|
|
required,
|
2026-04-01 03:55:00 +00:00
|
|
|
scope: source,
|
|
|
|
|
config: parsed,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 18:31:58 +09:00
|
|
|
fn mcp_invalid_server(
|
|
|
|
|
name: &str,
|
|
|
|
|
source: ConfigSource,
|
|
|
|
|
path: &Path,
|
|
|
|
|
context: &str,
|
|
|
|
|
error: &ConfigError,
|
|
|
|
|
) -> McpInvalidServerConfig {
|
|
|
|
|
let reason = config_error_detail(error);
|
|
|
|
|
McpInvalidServerConfig {
|
|
|
|
|
name: name.to_string(),
|
|
|
|
|
scope: source,
|
|
|
|
|
path: path.to_path_buf(),
|
|
|
|
|
error_field: mcp_error_field(name, context, &reason),
|
|
|
|
|
reason,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn config_error_detail(error: &ConfigError) -> String {
|
|
|
|
|
match error {
|
|
|
|
|
ConfigError::Io(error) => error.to_string(),
|
|
|
|
|
ConfigError::Parse(reason) => reason.clone(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn mcp_error_field(name: &str, context: &str, reason: &str) -> String {
|
|
|
|
|
if let Some(field) = reason
|
|
|
|
|
.split("missing string field ")
|
|
|
|
|
.nth(1)
|
|
|
|
|
.and_then(|tail| tail.split_whitespace().next())
|
|
|
|
|
{
|
|
|
|
|
return field
|
|
|
|
|
.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
|
|
|
|
|
.to_string();
|
|
|
|
|
}
|
|
|
|
|
if let Some(field) = reason
|
|
|
|
|
.split("field ")
|
|
|
|
|
.nth(1)
|
|
|
|
|
.and_then(|tail| tail.split_whitespace().next())
|
|
|
|
|
{
|
|
|
|
|
return field
|
|
|
|
|
.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
|
|
|
|
|
.to_string();
|
|
|
|
|
}
|
|
|
|
|
reason
|
|
|
|
|
.split_once(context)
|
|
|
|
|
.and_then(|(_, tail)| tail.trim_start_matches('.').split(':').next())
|
|
|
|
|
.filter(|field| !field.is_empty())
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
.unwrap_or_else(|| format!("mcpServers.{name}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_mcp_server_keys(
|
|
|
|
|
server_name: &str,
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<(), ConfigError> {
|
|
|
|
|
let server_type =
|
|
|
|
|
optional_string(object, "type", context)?.unwrap_or_else(|| infer_mcp_server_type(object));
|
|
|
|
|
let allowed = match server_type {
|
|
|
|
|
"stdio" => &[
|
|
|
|
|
"type",
|
|
|
|
|
"command",
|
|
|
|
|
"args",
|
|
|
|
|
"env",
|
|
|
|
|
"toolCallTimeoutMs",
|
|
|
|
|
"required",
|
|
|
|
|
][..],
|
|
|
|
|
"sse" | "http" => &[
|
|
|
|
|
"type",
|
|
|
|
|
"url",
|
|
|
|
|
"headers",
|
|
|
|
|
"headersHelper",
|
|
|
|
|
"oauth",
|
|
|
|
|
"required",
|
|
|
|
|
][..],
|
|
|
|
|
"ws" => &["type", "url", "headers", "headersHelper", "required"][..],
|
|
|
|
|
"sdk" => &["type", "name", "required"][..],
|
|
|
|
|
"claudeai-proxy" => &["type", "url", "id", "required"][..],
|
|
|
|
|
other => {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: unsupported MCP server type for {server_name}: {other}"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: unknown MCP server field {key}"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
|
|
|
|
root.as_object()
|
|
|
|
|
.and_then(|object| object.get("model"))
|
|
|
|
|
.and_then(JsonValue::as_str)
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 14:51:12 +09:00
|
|
|
fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>, ConfigError> {
|
|
|
|
|
let Some(object) = root.as_object() else {
|
|
|
|
|
return Ok(BTreeMap::new());
|
|
|
|
|
};
|
|
|
|
|
Ok(optional_string_map(object, "aliases", "merged settings")?.unwrap_or_default())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
|
|
|
|
|
let Some(object) = root.as_object() else {
|
|
|
|
|
return Ok(RuntimeHookConfig::default());
|
|
|
|
|
};
|
2026-04-04 15:15:29 +00:00
|
|
|
parse_optional_hooks_config_object(object, "merged settings.hooks")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_optional_hooks_config_object(
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<RuntimeHookConfig, ConfigError> {
|
2026-04-01 03:55:00 +00:00
|
|
|
let Some(hooks_value) = object.get("hooks") else {
|
|
|
|
|
return Ok(RuntimeHookConfig::default());
|
|
|
|
|
};
|
2026-04-04 15:15:29 +00:00
|
|
|
let hooks = expect_object(hooks_value, context)?;
|
2026-04-01 03:55:00 +00:00
|
|
|
Ok(RuntimeHookConfig {
|
2026-06-03 21:23:00 +09:00
|
|
|
pre_tool_use: optional_hook_command_array(hooks, "PreToolUse", context)?
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
post_tool_use: optional_hook_command_array(hooks, "PostToolUse", context)?
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
post_tool_use_failure: optional_hook_command_array(hooks, "PostToolUseFailure", context)?
|
2026-04-01 03:55:00 +00:00
|
|
|
.unwrap_or_default(),
|
2026-04-01 04:20:16 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:15:29 +00:00
|
|
|
fn validate_optional_hooks_config(
|
|
|
|
|
root: &BTreeMap<String, JsonValue>,
|
|
|
|
|
path: &Path,
|
|
|
|
|
) -> Result<(), ConfigError> {
|
|
|
|
|
parse_optional_hooks_config_object(root, &format!("{}: hooks", path.display())).map(|_| ())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:20:16 +00:00
|
|
|
fn parse_optional_permission_rules(
|
|
|
|
|
root: &JsonValue,
|
|
|
|
|
) -> Result<RuntimePermissionRuleConfig, ConfigError> {
|
|
|
|
|
let Some(object) = root.as_object() else {
|
|
|
|
|
return Ok(RuntimePermissionRuleConfig::default());
|
|
|
|
|
};
|
|
|
|
|
let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) else {
|
|
|
|
|
return Ok(RuntimePermissionRuleConfig::default());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(RuntimePermissionRuleConfig {
|
|
|
|
|
allow: optional_string_array(permissions, "allow", "merged settings.permissions")?
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
deny: optional_string_array(permissions, "deny", "merged settings.permissions")?
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
ask: optional_string_array(permissions, "ask", "merged settings.permissions")?
|
|
|
|
|
.unwrap_or_default(),
|
2026-05-25 12:01:09 +09:00
|
|
|
denied_tools: optional_string_array(
|
|
|
|
|
permissions,
|
|
|
|
|
"deniedTools",
|
|
|
|
|
"merged settings.permissions",
|
|
|
|
|
)?
|
|
|
|
|
.unwrap_or_default(),
|
2026-04-01 03:55:00 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:30:28 +00:00
|
|
|
fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
|
|
|
|
|
let Some(object) = root.as_object() else {
|
|
|
|
|
return Ok(RuntimePluginConfig::default());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut config = RuntimePluginConfig::default();
|
|
|
|
|
if let Some(enabled_plugins) = object.get("enabledPlugins") {
|
|
|
|
|
config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let Some(plugins_value) = object.get("plugins") else {
|
|
|
|
|
return Ok(config);
|
|
|
|
|
};
|
|
|
|
|
let plugins = expect_object(plugins_value, "merged settings.plugins")?;
|
|
|
|
|
|
|
|
|
|
if let Some(enabled_value) = plugins.get("enabled") {
|
|
|
|
|
config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
|
|
|
|
|
}
|
|
|
|
|
config.external_directories =
|
|
|
|
|
optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
config.install_root =
|
|
|
|
|
optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
|
|
|
|
|
config.registry_path =
|
|
|
|
|
optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
|
|
|
|
|
config.bundled_root =
|
|
|
|
|
optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
|
2026-04-07 15:52:11 +09:00
|
|
|
config.max_output_tokens = optional_u32(plugins, "maxOutputTokens", "merged settings.plugins")?;
|
2026-04-01 04:30:28 +00:00
|
|
|
Ok(config)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
fn parse_optional_permission_mode(
|
|
|
|
|
root: &JsonValue,
|
|
|
|
|
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
|
|
|
|
|
let Some(object) = root.as_object() else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
|
|
|
|
|
return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
|
|
|
|
|
}
|
|
|
|
|
let Some(mode) = object
|
|
|
|
|
.get("permissions")
|
|
|
|
|
.and_then(JsonValue::as_object)
|
|
|
|
|
.and_then(|permissions| permissions.get("defaultMode"))
|
|
|
|
|
.and_then(JsonValue::as_str)
|
|
|
|
|
else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_permission_mode_label(
|
|
|
|
|
mode: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<ResolvedPermissionMode, ConfigError> {
|
|
|
|
|
match mode {
|
|
|
|
|
"default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
|
|
|
|
|
"acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
|
|
|
|
|
"dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
|
|
|
|
|
other => Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: unsupported permission mode {other}"
|
|
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
|
|
|
|
|
let Some(object) = root.as_object() else {
|
|
|
|
|
return Ok(SandboxConfig::default());
|
|
|
|
|
};
|
|
|
|
|
let Some(sandbox_value) = object.get("sandbox") else {
|
|
|
|
|
return Ok(SandboxConfig::default());
|
|
|
|
|
};
|
|
|
|
|
let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
|
|
|
|
|
let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
|
|
|
|
|
.map(parse_filesystem_mode_label)
|
|
|
|
|
.transpose()?;
|
|
|
|
|
Ok(SandboxConfig {
|
|
|
|
|
enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
|
|
|
|
|
namespace_restrictions: optional_bool(
|
|
|
|
|
sandbox,
|
|
|
|
|
"namespaceRestrictions",
|
|
|
|
|
"merged settings.sandbox",
|
|
|
|
|
)?,
|
|
|
|
|
network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
|
|
|
|
|
filesystem_mode,
|
|
|
|
|
allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 14:51:12 +09:00
|
|
|
fn parse_optional_provider_fallbacks(
|
|
|
|
|
root: &JsonValue,
|
|
|
|
|
) -> Result<ProviderFallbackConfig, ConfigError> {
|
|
|
|
|
let Some(object) = root.as_object() else {
|
|
|
|
|
return Ok(ProviderFallbackConfig::default());
|
|
|
|
|
};
|
|
|
|
|
let Some(value) = object.get("providerFallbacks") else {
|
|
|
|
|
return Ok(ProviderFallbackConfig::default());
|
|
|
|
|
};
|
|
|
|
|
let entry = expect_object(value, "merged settings.providerFallbacks")?;
|
|
|
|
|
let primary =
|
|
|
|
|
optional_string(entry, "primary", "merged settings.providerFallbacks")?.map(str::to_string);
|
|
|
|
|
let fallbacks = optional_string_array(entry, "fallbacks", "merged settings.providerFallbacks")?
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
Ok(ProviderFallbackConfig { primary, fallbacks })
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 02:35:19 +09:00
|
|
|
fn parse_optional_trusted_roots(root: &JsonValue) -> Result<Vec<String>, ConfigError> {
|
|
|
|
|
let Some(object) = root.as_object() else {
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
};
|
2026-04-08 11:21:13 +09:00
|
|
|
Ok(
|
|
|
|
|
optional_string_array(object, "trustedRoots", "merged settings.trustedRoots")?
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
)
|
2026-04-08 02:35:19 +09:00
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:01:48 +09:00
|
|
|
fn parse_optional_rules_import(root: &JsonValue) -> Result<RulesImportConfig, ConfigError> {
|
|
|
|
|
let Some(object) = root.as_object() else {
|
|
|
|
|
return Ok(RulesImportConfig::default());
|
|
|
|
|
};
|
|
|
|
|
let Some(value) = object.get("rulesImport") else {
|
|
|
|
|
return Ok(RulesImportConfig::default());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match value {
|
|
|
|
|
JsonValue::String(value) if value.eq_ignore_ascii_case("auto") => Ok(RulesImportConfig::Auto),
|
|
|
|
|
JsonValue::String(value) if value.eq_ignore_ascii_case("none") => Ok(RulesImportConfig::None),
|
|
|
|
|
JsonValue::String(value) => Err(ConfigError::Parse(format!(
|
|
|
|
|
"merged settings.rulesImport: expected \"auto\", \"none\", or an array of framework names, got \"{value}\""
|
|
|
|
|
))),
|
|
|
|
|
JsonValue::Array(values) => values
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|item| {
|
|
|
|
|
item.as_str().map(str::to_string).ok_or_else(|| {
|
|
|
|
|
ConfigError::Parse(
|
|
|
|
|
"merged settings.rulesImport: array entries must be strings".to_string(),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect::<Result<Vec<_>, _>>()
|
|
|
|
|
.map(RulesImportConfig::List),
|
|
|
|
|
_ => Err(ConfigError::Parse(
|
|
|
|
|
"merged settings.rulesImport: expected \"auto\", \"none\", or an array of framework names".to_string(),
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
|
|
|
|
|
match value {
|
|
|
|
|
"off" => Ok(FilesystemIsolationMode::Off),
|
|
|
|
|
"workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
|
|
|
|
|
"allow-list" => Ok(FilesystemIsolationMode::AllowList),
|
|
|
|
|
other => Err(ConfigError::Parse(format!(
|
|
|
|
|
"merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
|
|
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_optional_oauth_config(
|
|
|
|
|
root: &JsonValue,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Option<OAuthConfig>, ConfigError> {
|
|
|
|
|
let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let object = expect_object(oauth_value, context)?;
|
|
|
|
|
let client_id = expect_string(object, "clientId", context)?.to_string();
|
|
|
|
|
let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
|
|
|
|
|
let token_url = expect_string(object, "tokenUrl", context)?.to_string();
|
|
|
|
|
let callback_port = optional_u16(object, "callbackPort", context)?;
|
|
|
|
|
let manual_redirect_url =
|
|
|
|
|
optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
|
|
|
|
|
let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
|
|
|
|
|
Ok(Some(OAuthConfig {
|
|
|
|
|
client_id,
|
|
|
|
|
authorize_url,
|
|
|
|
|
token_url,
|
|
|
|
|
callback_port,
|
|
|
|
|
manual_redirect_url,
|
|
|
|
|
scopes,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_mcp_server_config(
|
|
|
|
|
server_name: &str,
|
|
|
|
|
value: &JsonValue,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<McpServerConfig, ConfigError> {
|
|
|
|
|
let object = expect_object(value, context)?;
|
2026-04-04 14:50:31 +00:00
|
|
|
let server_type =
|
|
|
|
|
optional_string(object, "type", context)?.unwrap_or_else(|| infer_mcp_server_type(object));
|
2026-04-01 03:55:00 +00:00
|
|
|
match server_type {
|
|
|
|
|
"stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
|
2026-06-04 18:31:58 +09:00
|
|
|
command: expect_non_empty_string(object, "command", context)?.to_string(),
|
2026-04-01 03:55:00 +00:00
|
|
|
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
|
|
|
|
|
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
|
2026-04-02 18:24:30 +09:00
|
|
|
tool_call_timeout_ms: optional_u64(object, "toolCallTimeoutMs", context)?,
|
2026-04-01 03:55:00 +00:00
|
|
|
})),
|
|
|
|
|
"sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
|
|
|
|
|
object, context,
|
|
|
|
|
)?)),
|
|
|
|
|
"http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
|
|
|
|
|
object, context,
|
|
|
|
|
)?)),
|
|
|
|
|
"ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
|
|
|
|
|
url: expect_string(object, "url", context)?.to_string(),
|
|
|
|
|
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
|
|
|
|
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
|
|
|
|
})),
|
|
|
|
|
"sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
|
|
|
|
|
name: expect_string(object, "name", context)?.to_string(),
|
|
|
|
|
})),
|
2026-04-01 18:48:39 +09:00
|
|
|
"claudeai-proxy" => Ok(McpServerConfig::ManagedProxy(McpManagedProxyServerConfig {
|
|
|
|
|
url: expect_string(object, "url", context)?.to_string(),
|
|
|
|
|
id: expect_string(object, "id", context)?.to_string(),
|
|
|
|
|
})),
|
2026-04-01 03:55:00 +00:00
|
|
|
other => Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: unsupported MCP server type for {server_name}: {other}"
|
|
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 14:50:31 +00:00
|
|
|
fn infer_mcp_server_type(object: &BTreeMap<String, JsonValue>) -> &'static str {
|
|
|
|
|
if object.contains_key("url") {
|
|
|
|
|
"http"
|
|
|
|
|
} else {
|
|
|
|
|
"stdio"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
fn parse_mcp_remote_server_config(
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<McpRemoteServerConfig, ConfigError> {
|
|
|
|
|
Ok(McpRemoteServerConfig {
|
|
|
|
|
url: expect_string(object, "url", context)?.to_string(),
|
|
|
|
|
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
|
|
|
|
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
|
|
|
|
oauth: parse_optional_mcp_oauth_config(object, context)?,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_optional_mcp_oauth_config(
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Option<McpOAuthConfig>, ConfigError> {
|
|
|
|
|
let Some(value) = object.get("oauth") else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let oauth = expect_object(value, &format!("{context}.oauth"))?;
|
|
|
|
|
Ok(Some(McpOAuthConfig {
|
|
|
|
|
client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
|
|
|
|
|
callback_port: optional_u16(oauth, "callbackPort", context)?,
|
|
|
|
|
auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
|
|
|
|
|
.map(str::to_string),
|
|
|
|
|
xaa: optional_bool(oauth, "xaa", context)?,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn expect_object<'a>(
|
|
|
|
|
value: &'a JsonValue,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
|
|
|
|
|
value
|
|
|
|
|
.as_object()
|
|
|
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 18:31:58 +09:00
|
|
|
fn expect_non_empty_string<'a>(
|
|
|
|
|
object: &'a BTreeMap<String, JsonValue>,
|
|
|
|
|
key: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<&'a str, ConfigError> {
|
|
|
|
|
let value = expect_string(object, key, context)?;
|
|
|
|
|
if value.trim().is_empty() {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key} must be a non-empty string"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
Ok(value)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
fn expect_string<'a>(
|
|
|
|
|
object: &'a BTreeMap<String, JsonValue>,
|
|
|
|
|
key: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<&'a str, ConfigError> {
|
|
|
|
|
object
|
|
|
|
|
.get(key)
|
|
|
|
|
.and_then(JsonValue::as_str)
|
|
|
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn optional_string<'a>(
|
|
|
|
|
object: &'a BTreeMap<String, JsonValue>,
|
|
|
|
|
key: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Option<&'a str>, ConfigError> {
|
|
|
|
|
match object.get(key) {
|
|
|
|
|
Some(value) => value
|
|
|
|
|
.as_str()
|
|
|
|
|
.map(Some)
|
|
|
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
|
|
|
|
|
None => Ok(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn optional_bool(
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
key: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Option<bool>, ConfigError> {
|
|
|
|
|
match object.get(key) {
|
|
|
|
|
Some(value) => value
|
|
|
|
|
.as_bool()
|
|
|
|
|
.map(Some)
|
|
|
|
|
.ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
|
|
|
|
|
None => Ok(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn optional_u16(
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
key: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Option<u16>, ConfigError> {
|
|
|
|
|
match object.get(key) {
|
|
|
|
|
Some(value) => {
|
|
|
|
|
let Some(number) = value.as_i64() else {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key} must be an integer"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
let number = u16::try_from(number).map_err(|_| {
|
|
|
|
|
ConfigError::Parse(format!("{context}: field {key} is out of range"))
|
2026-04-07 15:52:11 +09:00
|
|
|
})?;
|
|
|
|
|
Ok(Some(number))
|
|
|
|
|
}
|
|
|
|
|
None => Ok(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn optional_u32(
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
key: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Option<u32>, ConfigError> {
|
|
|
|
|
match object.get(key) {
|
|
|
|
|
Some(value) => {
|
|
|
|
|
let Some(number) = value.as_i64() else {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key} must be a non-negative integer"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
let number = u32::try_from(number).map_err(|_| {
|
|
|
|
|
ConfigError::Parse(format!("{context}: field {key} is out of range"))
|
2026-04-02 18:24:30 +09:00
|
|
|
})?;
|
|
|
|
|
Ok(Some(number))
|
|
|
|
|
}
|
|
|
|
|
None => Ok(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn optional_u64(
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
key: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Option<u64>, ConfigError> {
|
|
|
|
|
match object.get(key) {
|
|
|
|
|
Some(value) => {
|
|
|
|
|
let Some(number) = value.as_i64() else {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key} must be a non-negative integer"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
let number = u64::try_from(number).map_err(|_| {
|
2026-04-01 03:55:00 +00:00
|
|
|
ConfigError::Parse(format!("{context}: field {key} is out of range"))
|
|
|
|
|
})?;
|
|
|
|
|
Ok(Some(number))
|
|
|
|
|
}
|
|
|
|
|
None => Ok(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:30:28 +00:00
|
|
|
fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
|
|
|
|
|
let Some(map) = value.as_object() else {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: expected JSON object"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
map.iter()
|
|
|
|
|
.map(|(key, value)| {
|
|
|
|
|
value
|
|
|
|
|
.as_bool()
|
|
|
|
|
.map(|enabled| (key.clone(), enabled))
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
fn optional_string_array(
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
key: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Option<Vec<String>>, ConfigError> {
|
|
|
|
|
match object.get(key) {
|
|
|
|
|
Some(value) => {
|
|
|
|
|
let Some(array) = value.as_array() else {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key} must be an array"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
array
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|item| {
|
|
|
|
|
item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
|
|
|
|
|
ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key} must contain only strings"
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect::<Result<Vec<_>, _>>()
|
|
|
|
|
.map(Some)
|
|
|
|
|
}
|
|
|
|
|
None => Ok(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:23:00 +09:00
|
|
|
fn optional_hook_command_array(
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
key: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Option<Vec<RuntimeHookCommand>>, ConfigError> {
|
|
|
|
|
let Some(value) = object.get(key) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let Some(array) = value.as_array() else {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key} must be an array"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut commands = Vec::new();
|
|
|
|
|
for (index, item) in array.iter().enumerate() {
|
|
|
|
|
if let Some(command) = item.as_str() {
|
|
|
|
|
commands.push(RuntimeHookCommand::new(command.to_string()));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let Some(entry) = item.as_object() else {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key}[{index}] must be a string or hook object"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
let matcher = optional_hook_matcher(entry, context, key, index)?;
|
|
|
|
|
let hooks = entry
|
|
|
|
|
.get("hooks")
|
|
|
|
|
.and_then(JsonValue::as_array)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key}[{index}].hooks must be an array"
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
for (hook_index, hook) in hooks.iter().enumerate() {
|
|
|
|
|
let Some(hook_object) = hook.as_object() else {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key}[{index}].hooks[{hook_index}] must be an object"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
if let Some(hook_type) = hook_object.get("type") {
|
|
|
|
|
let Some(hook_type) = hook_type.as_str() else {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key}[{index}].hooks[{hook_index}].type must be a string"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
if hook_type != "command" {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key}[{index}].hooks[{hook_index}].type must be \"command\""
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let command = hook_object
|
|
|
|
|
.get("command")
|
|
|
|
|
.and_then(JsonValue::as_str)
|
|
|
|
|
.filter(|command| !command.trim().is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key}[{index}].hooks[{hook_index}].command must be a non-empty string"
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
commands.push(RuntimeHookCommand::with_matcher(
|
|
|
|
|
command.to_string(),
|
|
|
|
|
matcher.clone(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(Some(commands))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn optional_hook_matcher(
|
|
|
|
|
entry: &BTreeMap<String, JsonValue>,
|
|
|
|
|
context: &str,
|
|
|
|
|
key: &str,
|
|
|
|
|
index: usize,
|
|
|
|
|
) -> Result<Option<String>, ConfigError> {
|
|
|
|
|
entry
|
|
|
|
|
.get("matcher")
|
|
|
|
|
.map(|value| {
|
|
|
|
|
value.as_str().map(str::to_string).ok_or_else(|| {
|
|
|
|
|
ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key}[{index}].matcher must be a string"
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.transpose()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn extend_unique_hook_commands(
|
|
|
|
|
target: &mut Vec<RuntimeHookCommand>,
|
|
|
|
|
values: &[RuntimeHookCommand],
|
|
|
|
|
) {
|
|
|
|
|
for value in values {
|
|
|
|
|
if !target.iter().any(|existing| existing == value) {
|
|
|
|
|
target.push(value.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
fn optional_string_map(
|
|
|
|
|
object: &BTreeMap<String, JsonValue>,
|
|
|
|
|
key: &str,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
|
|
|
|
|
match object.get(key) {
|
|
|
|
|
Some(value) => {
|
|
|
|
|
let Some(map) = value.as_object() else {
|
|
|
|
|
return Err(ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key} must be an object"
|
|
|
|
|
)));
|
|
|
|
|
};
|
|
|
|
|
map.iter()
|
|
|
|
|
.map(|(entry_key, entry_value)| {
|
|
|
|
|
entry_value
|
|
|
|
|
.as_str()
|
|
|
|
|
.map(|text| (entry_key.clone(), text.to_string()))
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
ConfigError::Parse(format!(
|
|
|
|
|
"{context}: field {key} must contain only string values"
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect::<Result<BTreeMap<_, _>, _>>()
|
|
|
|
|
.map(Some)
|
|
|
|
|
}
|
|
|
|
|
None => Ok(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn deep_merge_objects(
|
|
|
|
|
target: &mut BTreeMap<String, JsonValue>,
|
|
|
|
|
source: &BTreeMap<String, JsonValue>,
|
|
|
|
|
) {
|
|
|
|
|
for (key, value) in source {
|
|
|
|
|
match (target.get_mut(key), value) {
|
|
|
|
|
(Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
|
|
|
|
|
deep_merge_objects(existing, incoming);
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
target.insert(key.clone(), value.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::{
|
2026-06-03 21:46:47 +09:00
|
|
|
deep_merge_objects, parse_permission_mode_label, ConfigFileStatus, ConfigLoader,
|
|
|
|
|
ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig,
|
2026-06-03 21:23:00 +09:00
|
|
|
RuntimeHookCommand, RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
2026-04-01 03:55:00 +00:00
|
|
|
};
|
|
|
|
|
use crate::json::JsonValue;
|
|
|
|
|
use crate::sandbox::FilesystemIsolationMode;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
|
|
|
|
|
fn temp_dir() -> std::path::PathBuf {
|
2026-04-21 20:54:12 +09:00
|
|
|
// #149: previously used `runtime-config-{nanos}` which collided
|
|
|
|
|
// under parallel `cargo test --workspace` when multiple tests
|
|
|
|
|
// started within the same nanosecond bucket on fast machines.
|
|
|
|
|
// Add process id + a monotonically-incrementing atomic counter
|
|
|
|
|
// so every callsite gets a provably-unique directory regardless
|
|
|
|
|
// of clock resolution or scheduling.
|
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
2026-04-01 03:55:00 +00:00
|
|
|
let nanos = SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.expect("time should be after epoch")
|
|
|
|
|
.as_nanos();
|
2026-04-21 20:54:12 +09:00
|
|
|
let pid = std::process::id();
|
|
|
|
|
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_non_object_settings_files() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
2026-04-01 18:48:39 +09:00
|
|
|
let home = root.join("home").join(".claw");
|
2026-04-01 03:55:00 +00:00
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(home.join("settings.json"), "[]").expect("write bad settings");
|
|
|
|
|
|
|
|
|
|
let error = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect_err("config should fail");
|
|
|
|
|
assert!(error
|
|
|
|
|
.to_string()
|
|
|
|
|
.contains("top-level settings value must be a JSON object"));
|
|
|
|
|
|
2026-04-02 11:31:53 +09:00
|
|
|
if root.exists() {
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn loads_and_merges_claude_code_config_files_by_precedence() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
2026-04-01 18:48:39 +09:00
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
2026-04-01 03:55:00 +00:00
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
|
|
|
|
|
fs::write(
|
2026-04-01 18:48:39 +09:00
|
|
|
home.parent().expect("home parent").join(".claw.json"),
|
2026-04-01 03:55:00 +00:00
|
|
|
r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write user compat config");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
2026-04-01 04:30:25 +00:00
|
|
|
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan","allow":["Read"],"deny":["Bash(rm -rf)"]}}"#,
|
2026-04-01 03:55:00 +00:00
|
|
|
)
|
|
|
|
|
.expect("write user settings");
|
|
|
|
|
fs::write(
|
2026-04-01 18:48:39 +09:00
|
|
|
cwd.join(".claw.json"),
|
2026-04-01 03:55:00 +00:00
|
|
|
r#"{"model":"project-compat","env":{"B":"2"}}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write project compat config");
|
|
|
|
|
fs::write(
|
2026-04-01 18:48:39 +09:00
|
|
|
cwd.join(".claw").join("settings.json"),
|
2026-04-01 04:30:25 +00:00
|
|
|
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"],"PostToolUseFailure":["project-failure"]},"permissions":{"ask":["Edit"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
|
2026-04-01 03:55:00 +00:00
|
|
|
)
|
|
|
|
|
.expect("write project settings");
|
|
|
|
|
fs::write(
|
2026-04-01 18:48:39 +09:00
|
|
|
cwd.join(".claw").join("settings.local.json"),
|
2026-04-01 03:55:00 +00:00
|
|
|
r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write local settings");
|
|
|
|
|
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
2026-04-01 18:48:39 +09:00
|
|
|
assert_eq!(CLAW_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
2026-04-01 03:55:00 +00:00
|
|
|
assert_eq!(loaded.loaded_entries().len(), 5);
|
|
|
|
|
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.get("model"),
|
|
|
|
|
Some(&JsonValue::String("opus".to_string()))
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(loaded.model(), Some("opus"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.permission_mode(),
|
|
|
|
|
Some(ResolvedPermissionMode::WorkspaceWrite)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded
|
|
|
|
|
.get("env")
|
|
|
|
|
.and_then(JsonValue::as_object)
|
|
|
|
|
.expect("env object")
|
|
|
|
|
.len(),
|
|
|
|
|
4
|
|
|
|
|
);
|
|
|
|
|
assert!(loaded
|
|
|
|
|
.get("hooks")
|
|
|
|
|
.and_then(JsonValue::as_object)
|
|
|
|
|
.expect("hooks object")
|
|
|
|
|
.contains_key("PreToolUse"));
|
|
|
|
|
assert!(loaded
|
|
|
|
|
.get("hooks")
|
|
|
|
|
.and_then(JsonValue::as_object)
|
|
|
|
|
.expect("hooks object")
|
|
|
|
|
.contains_key("PostToolUse"));
|
|
|
|
|
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
|
|
|
|
|
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
|
2026-04-01 04:30:25 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
loaded.hooks().post_tool_use_failure(),
|
|
|
|
|
&["project-failure".to_string()]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.permission_rules().deny(),
|
|
|
|
|
&["Bash(rm -rf)".to_string()]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(loaded.permission_rules().ask(), &["Edit".to_string()]);
|
2026-04-01 03:55:00 +00:00
|
|
|
assert!(loaded.mcp().get("home").is_some());
|
|
|
|
|
assert!(loaded.mcp().get("project").is_some());
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:23:00 +09:00
|
|
|
#[test]
|
|
|
|
|
fn parses_object_style_hook_entries_with_matchers() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"bash-one"},{"type":"command","command":"bash-two"}]},{"matcher":"Read*","hooks":[{"command":"read-any"}]}]}}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write settings");
|
|
|
|
|
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.hooks().pre_tool_use(),
|
|
|
|
|
vec![
|
|
|
|
|
"legacy".to_string(),
|
|
|
|
|
"bash-one".to_string(),
|
|
|
|
|
"bash-two".to_string(),
|
|
|
|
|
"read-any".to_string(),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
let entries = loaded.hooks().pre_tool_use_entries();
|
|
|
|
|
assert_eq!(entries[0], RuntimeHookCommand::new("legacy"));
|
|
|
|
|
assert_eq!(entries[1].matcher(), Some("Bash"));
|
|
|
|
|
assert!(entries[1].matches_tool("bash"));
|
|
|
|
|
assert!(!entries[1].matches_tool("Read"));
|
|
|
|
|
assert!(entries[3].matches_tool("ReadFile"));
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_object_style_hook_entries_without_command() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command"}]}]}}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write settings");
|
|
|
|
|
|
|
|
|
|
let error = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect_err("config should reject malformed hook entry");
|
|
|
|
|
|
|
|
|
|
assert!(error
|
|
|
|
|
.to_string()
|
|
|
|
|
.contains("command must be a non-empty string"));
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:46:47 +09:00
|
|
|
#[test]
|
|
|
|
|
fn inspect_classifies_missing_loaded_and_legacy_skipped_files() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::write(cwd.join(".claw.json"), "{not json").expect("write legacy config");
|
|
|
|
|
fs::write(
|
|
|
|
|
cwd.join(".claw").join("settings.json"),
|
|
|
|
|
r#"{"model":"opus"}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write project settings");
|
|
|
|
|
|
|
|
|
|
let inspection = ConfigLoader::new(&cwd, &home).inspect_collecting_warnings();
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
inspection.load_error.is_none(),
|
|
|
|
|
"{:?}",
|
|
|
|
|
inspection.load_error
|
|
|
|
|
);
|
|
|
|
|
assert!(inspection.runtime_config.is_some());
|
|
|
|
|
let loaded = inspection
|
|
|
|
|
.files
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|file| file.status == ConfigFileStatus::Loaded)
|
|
|
|
|
.expect("loaded file");
|
|
|
|
|
assert!(loaded.loaded);
|
|
|
|
|
assert!(loaded.reason.is_none());
|
|
|
|
|
let missing = inspection
|
|
|
|
|
.files
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|file| file.status == ConfigFileStatus::NotFound)
|
|
|
|
|
.expect("missing file");
|
|
|
|
|
assert_eq!(missing.reason.as_deref(), Some("not_found"));
|
|
|
|
|
let skipped = inspection
|
|
|
|
|
.files
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|file| file.status == ConfigFileStatus::Skipped)
|
|
|
|
|
.expect("skipped legacy file");
|
|
|
|
|
assert_eq!(skipped.reason.as_deref(), Some("legacy_invalid_json"));
|
|
|
|
|
assert!(!skipped.loaded);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn inspect_reports_parse_errors_but_keeps_valid_merged_config() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::write(home.join("settings.json"), r#"{"model":"sonnet"}"#)
|
|
|
|
|
.expect("write user settings");
|
|
|
|
|
fs::write(cwd.join(".claw").join("settings.json"), "{not json")
|
|
|
|
|
.expect("write invalid project settings");
|
|
|
|
|
|
|
|
|
|
let inspection = ConfigLoader::new(&cwd, &home).inspect_collecting_warnings();
|
|
|
|
|
|
|
|
|
|
assert!(inspection
|
|
|
|
|
.load_error
|
|
|
|
|
.as_deref()
|
|
|
|
|
.is_some_and(|error| error.contains("settings.json")));
|
|
|
|
|
let runtime_config = inspection.runtime_config.expect("valid files still merge");
|
|
|
|
|
assert_eq!(runtime_config.model(), Some("sonnet"));
|
|
|
|
|
let error_file = inspection
|
|
|
|
|
.files
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|file| file.status == ConfigFileStatus::LoadError)
|
|
|
|
|
.expect("load error file");
|
|
|
|
|
assert_eq!(error_file.reason.as_deref(), Some("parse_error"));
|
|
|
|
|
assert!(error_file
|
|
|
|
|
.detail
|
|
|
|
|
.as_deref()
|
|
|
|
|
.is_some_and(|detail| detail.contains("settings.json")));
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[test]
|
|
|
|
|
fn parses_sandbox_config() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
2026-04-01 18:48:39 +09:00
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
2026-04-01 03:55:00 +00:00
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
|
|
|
|
|
fs::write(
|
2026-04-01 18:48:39 +09:00
|
|
|
cwd.join(".claw").join("settings.local.json"),
|
2026-04-01 03:55:00 +00:00
|
|
|
r#"{
|
|
|
|
|
"sandbox": {
|
|
|
|
|
"enabled": true,
|
|
|
|
|
"namespaceRestrictions": false,
|
|
|
|
|
"networkIsolation": true,
|
|
|
|
|
"filesystemMode": "allow-list",
|
|
|
|
|
"allowedMounts": ["logs", "tmp/cache"]
|
|
|
|
|
}
|
|
|
|
|
}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write local settings");
|
|
|
|
|
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
assert_eq!(loaded.sandbox().enabled, Some(true));
|
|
|
|
|
assert_eq!(loaded.sandbox().namespace_restrictions, Some(false));
|
|
|
|
|
assert_eq!(loaded.sandbox().network_isolation, Some(true));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.sandbox().filesystem_mode,
|
|
|
|
|
Some(FilesystemIsolationMode::AllowList)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 14:51:12 +09:00
|
|
|
#[test]
|
|
|
|
|
fn parses_provider_fallbacks_chain_with_primary_and_ordered_fallbacks() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{
|
|
|
|
|
"providerFallbacks": {
|
|
|
|
|
"primary": "claude-opus-4-6",
|
|
|
|
|
"fallbacks": ["grok-3", "grok-3-mini"]
|
|
|
|
|
}
|
|
|
|
|
}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write provider fallback settings");
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
let chain = loaded.provider_fallbacks();
|
|
|
|
|
assert_eq!(chain.primary(), Some("claude-opus-4-6"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
chain.fallbacks(),
|
|
|
|
|
&["grok-3".to_string(), "grok-3-mini".to_string()]
|
|
|
|
|
);
|
|
|
|
|
assert!(!chain.is_empty());
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn provider_fallbacks_default_is_empty_when_unset() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(home.join("settings.json"), "{}").expect("write empty settings");
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
let chain = loaded.provider_fallbacks();
|
|
|
|
|
assert_eq!(chain.primary(), None);
|
|
|
|
|
assert!(chain.fallbacks().is_empty());
|
|
|
|
|
assert!(chain.is_empty());
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:01:48 +09:00
|
|
|
#[test]
|
|
|
|
|
fn parses_rules_import_config() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{"rulesImport": ["cursor", "copilot"]}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write settings");
|
|
|
|
|
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
assert!(loaded.rules_import().should_import("cursor"));
|
|
|
|
|
assert!(loaded.rules_import().should_import("copilot"));
|
|
|
|
|
assert!(!loaded.rules_import().should_import("windsurf"));
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rules_import_none_disables_external_frameworks() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(home.join("settings.json"), r#"{"rulesImport": "none"}"#)
|
|
|
|
|
.expect("write settings");
|
|
|
|
|
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
assert!(!loaded.rules_import().should_import("cursor"));
|
|
|
|
|
assert!(!loaded.rules_import().should_import("copilot"));
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_rules_import_array_with_non_string_entries() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{"rulesImport": ["cursor", 42]}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write settings");
|
|
|
|
|
|
|
|
|
|
let error = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect_err("config should fail");
|
|
|
|
|
|
|
|
|
|
assert!(error.to_string().contains("rulesImport"));
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 02:35:19 +09:00
|
|
|
#[test]
|
|
|
|
|
fn parses_trusted_roots_from_settings() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{"trustedRoots": ["/tmp/worktrees", "/home/user/projects"]}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write settings");
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
let roots = loaded.trusted_roots();
|
|
|
|
|
assert_eq!(roots, ["/tmp/worktrees", "/home/user/projects"]);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 17:40:28 +09:00
|
|
|
#[test]
|
|
|
|
|
fn trusted_roots_with_overrides_preserves_config_defaults_and_adds_per_call_roots() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{"trustedRoots": ["/tmp/config-default", "/tmp/shared"]}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write settings");
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
let merged = loaded.trusted_roots_with_overrides(&[
|
|
|
|
|
"/tmp/per-call".to_string(),
|
|
|
|
|
"/tmp/shared".to_string(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
assert_eq!(
|
|
|
|
|
merged,
|
|
|
|
|
["/tmp/config-default", "/tmp/shared", "/tmp/per-call"]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn runtime_feature_trusted_roots_with_overrides_matches_runtime_config_merge() {
|
|
|
|
|
let config = RuntimeFeatureConfig {
|
|
|
|
|
trusted_roots: vec!["/tmp/config".to_string()],
|
|
|
|
|
..RuntimeFeatureConfig::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
config.trusted_roots_with_overrides(&["/tmp/per-call".to_string()]),
|
|
|
|
|
["/tmp/config", "/tmp/per-call"]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 02:35:19 +09:00
|
|
|
#[test]
|
|
|
|
|
fn trusted_roots_default_is_empty_when_unset() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(home.join("settings.json"), "{}").expect("write empty settings");
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
assert!(loaded.trusted_roots().is_empty());
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[test]
|
|
|
|
|
fn parses_typed_mcp_and_oauth_config() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
2026-04-01 18:48:39 +09:00
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
2026-04-01 03:55:00 +00:00
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{
|
|
|
|
|
"mcpServers": {
|
|
|
|
|
"stdio-server": {
|
|
|
|
|
"command": "uvx",
|
|
|
|
|
"args": ["mcp-server"],
|
2026-05-15 10:05:41 +09:00
|
|
|
"env": {"TOKEN": "secret"},
|
|
|
|
|
"required": true
|
2026-04-01 03:55:00 +00:00
|
|
|
},
|
|
|
|
|
"remote-server": {
|
|
|
|
|
"type": "http",
|
|
|
|
|
"url": "https://example.test/mcp",
|
|
|
|
|
"headers": {"Authorization": "Bearer token"},
|
|
|
|
|
"headersHelper": "helper.sh",
|
|
|
|
|
"oauth": {
|
|
|
|
|
"clientId": "mcp-client",
|
|
|
|
|
"callbackPort": 7777,
|
|
|
|
|
"authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
|
|
|
|
|
"xaa": true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"oauth": {
|
|
|
|
|
"clientId": "runtime-client",
|
|
|
|
|
"authorizeUrl": "https://console.test/oauth/authorize",
|
|
|
|
|
"tokenUrl": "https://console.test/oauth/token",
|
|
|
|
|
"callbackPort": 54545,
|
|
|
|
|
"manualRedirectUrl": "https://console.test/oauth/callback",
|
|
|
|
|
"scopes": ["org:read", "user:write"]
|
|
|
|
|
}
|
|
|
|
|
}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write user settings");
|
|
|
|
|
fs::write(
|
2026-04-01 18:48:39 +09:00
|
|
|
cwd.join(".claw").join("settings.local.json"),
|
2026-04-01 03:55:00 +00:00
|
|
|
r#"{
|
|
|
|
|
"mcpServers": {
|
|
|
|
|
"remote-server": {
|
|
|
|
|
"type": "ws",
|
|
|
|
|
"url": "wss://override.test/mcp",
|
|
|
|
|
"headers": {"X-Env": "local"}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write local settings");
|
|
|
|
|
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
let stdio_server = loaded
|
|
|
|
|
.mcp()
|
|
|
|
|
.get("stdio-server")
|
|
|
|
|
.expect("stdio server should exist");
|
|
|
|
|
assert_eq!(stdio_server.scope, ConfigSource::User);
|
2026-05-15 10:05:41 +09:00
|
|
|
assert!(stdio_server.required);
|
2026-04-01 03:55:00 +00:00
|
|
|
assert_eq!(stdio_server.transport(), McpTransport::Stdio);
|
|
|
|
|
|
|
|
|
|
let remote_server = loaded
|
|
|
|
|
.mcp()
|
|
|
|
|
.get("remote-server")
|
|
|
|
|
.expect("remote server should exist");
|
|
|
|
|
assert_eq!(remote_server.scope, ConfigSource::Local);
|
2026-05-15 10:05:41 +09:00
|
|
|
assert!(!remote_server.required);
|
2026-04-01 03:55:00 +00:00
|
|
|
assert_eq!(remote_server.transport(), McpTransport::Ws);
|
|
|
|
|
match &remote_server.config {
|
|
|
|
|
McpServerConfig::Ws(config) => {
|
|
|
|
|
assert_eq!(config.url, "wss://override.test/mcp");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
config.headers.get("X-Env").map(String::as_str),
|
|
|
|
|
Some("local")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
other => panic!("expected ws config, got {other:?}"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let oauth = loaded.oauth().expect("oauth config should exist");
|
|
|
|
|
assert_eq!(oauth.client_id, "runtime-client");
|
|
|
|
|
assert_eq!(oauth.callback_port, Some(54_545));
|
|
|
|
|
assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 14:50:31 +00:00
|
|
|
#[test]
|
|
|
|
|
fn infers_http_mcp_servers_from_url_only_config() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{
|
|
|
|
|
"mcpServers": {
|
|
|
|
|
"remote": {
|
|
|
|
|
"url": "https://example.test/mcp"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write mcp settings");
|
|
|
|
|
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
2026-04-04 15:23:29 +00:00
|
|
|
let remote_server = loaded
|
|
|
|
|
.mcp()
|
|
|
|
|
.get("remote")
|
|
|
|
|
.expect("remote server should exist");
|
2026-04-04 14:50:31 +00:00
|
|
|
assert_eq!(remote_server.transport(), McpTransport::Http);
|
|
|
|
|
match &remote_server.config {
|
|
|
|
|
McpServerConfig::Http(config) => {
|
|
|
|
|
assert_eq!(config.url, "https://example.test/mcp");
|
|
|
|
|
}
|
|
|
|
|
other => panic!("expected http config, got {other:?}"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:30:28 +00:00
|
|
|
#[test]
|
|
|
|
|
fn parses_plugin_config_from_enabled_plugins() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
2026-04-01 18:48:39 +09:00
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
2026-04-01 04:30:28 +00:00
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{
|
|
|
|
|
"enabledPlugins": {
|
|
|
|
|
"tool-guard@builtin": true,
|
|
|
|
|
"sample-plugin@external": false
|
|
|
|
|
}
|
|
|
|
|
}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write user settings");
|
|
|
|
|
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.plugins().enabled_plugins().get("tool-guard@builtin"),
|
|
|
|
|
Some(&true)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded
|
|
|
|
|
.plugins()
|
|
|
|
|
.enabled_plugins()
|
|
|
|
|
.get("sample-plugin@external"),
|
|
|
|
|
Some(&false)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_plugin_config() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
2026-04-01 18:48:39 +09:00
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
2026-04-01 04:30:28 +00:00
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{
|
|
|
|
|
"enabledPlugins": {
|
|
|
|
|
"core-helpers@builtin": true
|
|
|
|
|
},
|
|
|
|
|
"plugins": {
|
|
|
|
|
"externalDirectories": ["./external-plugins"],
|
|
|
|
|
"installRoot": "plugin-cache/installed",
|
|
|
|
|
"registryPath": "plugin-cache/installed.json",
|
|
|
|
|
"bundledRoot": "./bundled-plugins"
|
|
|
|
|
}
|
|
|
|
|
}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write plugin settings");
|
|
|
|
|
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded
|
|
|
|
|
.plugins()
|
|
|
|
|
.enabled_plugins()
|
|
|
|
|
.get("core-helpers@builtin"),
|
|
|
|
|
Some(&true)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.plugins().external_directories(),
|
|
|
|
|
&["./external-plugins".to_string()]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.plugins().install_root(),
|
|
|
|
|
Some("plugin-cache/installed")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.plugins().registry_path(),
|
|
|
|
|
Some("plugin-cache/installed.json")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 03:55:00 +00:00
|
|
|
#[test]
|
2026-06-04 18:31:58 +09:00
|
|
|
fn records_invalid_mcp_server_shapes_without_rejecting_config_440() {
|
2026-04-02 18:10:12 +09:00
|
|
|
// given
|
2026-04-01 03:55:00 +00:00
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
2026-04-01 18:48:39 +09:00
|
|
|
let home = root.join("home").join(".claw");
|
2026-04-01 03:55:00 +00:00
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write broken settings");
|
|
|
|
|
|
2026-04-02 18:10:12 +09:00
|
|
|
// when
|
2026-06-04 18:31:58 +09:00
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
2026-04-01 03:55:00 +00:00
|
|
|
.load()
|
2026-06-04 18:31:58 +09:00
|
|
|
.expect("invalid MCP entries should not block otherwise loadable config");
|
2026-04-02 18:10:12 +09:00
|
|
|
|
|
|
|
|
// then
|
2026-06-04 18:31:58 +09:00
|
|
|
assert!(loaded.mcp().servers().is_empty());
|
|
|
|
|
assert_eq!(loaded.mcp().total_configured(), 1);
|
|
|
|
|
assert_eq!(loaded.mcp().invalid_count(), 1);
|
|
|
|
|
let invalid = &loaded.mcp().invalid_servers()[0];
|
|
|
|
|
assert_eq!(invalid.name, "broken");
|
|
|
|
|
assert_eq!(invalid.error_field, "url");
|
|
|
|
|
assert!(invalid
|
|
|
|
|
.reason
|
2026-04-01 03:55:00 +00:00
|
|
|
.contains("mcpServers.broken: missing string field url"));
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
2026-04-02 18:10:12 +09:00
|
|
|
|
2026-06-04 18:31:58 +09:00
|
|
|
#[test]
|
|
|
|
|
fn loads_valid_mcp_servers_and_collects_all_invalid_siblings_440() {
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{
|
|
|
|
|
"mcpServers": {
|
|
|
|
|
"valid-server": {"command": "/bin/echo", "args": ["hello"]},
|
|
|
|
|
"missing-command": {"args": ["arg-only"]},
|
|
|
|
|
"empty-command": {"command": ""},
|
|
|
|
|
"wrong-type-command": {"command": 42},
|
|
|
|
|
"extra-unknown-field": {"command": "/bin/echo", "extra": true}
|
|
|
|
|
}
|
|
|
|
|
}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write mixed settings");
|
|
|
|
|
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("valid MCP entries should load beside invalid siblings");
|
|
|
|
|
|
|
|
|
|
assert_eq!(loaded.mcp().total_configured(), 5);
|
|
|
|
|
assert_eq!(loaded.mcp().valid_count(), 1);
|
|
|
|
|
assert_eq!(loaded.mcp().invalid_count(), 4);
|
|
|
|
|
assert!(loaded.mcp().get("valid-server").is_some());
|
|
|
|
|
let invalid_names = loaded
|
|
|
|
|
.mcp()
|
|
|
|
|
.invalid_servers()
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|server| server.name.as_str())
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
invalid_names,
|
|
|
|
|
vec![
|
|
|
|
|
"empty-command",
|
|
|
|
|
"extra-unknown-field",
|
|
|
|
|
"missing-command",
|
|
|
|
|
"wrong-type-command",
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 14:51:12 +09:00
|
|
|
#[test]
|
|
|
|
|
fn parses_user_defined_model_aliases_from_settings() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"claude-opus-4-6"}}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write user settings");
|
|
|
|
|
fs::write(
|
|
|
|
|
cwd.join(".claw").join("settings.local.json"),
|
|
|
|
|
r#"{"aliases":{"smart":"claude-sonnet-4-6","cheap":"grok-3-mini"}}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write local settings");
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("config should load");
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
let aliases = loaded.aliases();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
aliases.get("fast").map(String::as_str),
|
|
|
|
|
Some("claude-haiku-4-5-20251213")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
aliases.get("smart").map(String::as_str),
|
|
|
|
|
Some("claude-sonnet-4-6")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
aliases.get("cheap").map(String::as_str),
|
|
|
|
|
Some("grok-3-mini")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 18:10:12 +09:00
|
|
|
#[test]
|
|
|
|
|
fn empty_settings_file_loads_defaults() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(home.join("settings.json"), "").expect("write empty settings");
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
let loaded = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect("empty settings should still load");
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
assert_eq!(loaded.loaded_entries().len(), 1);
|
|
|
|
|
assert_eq!(loaded.permission_mode(), None);
|
|
|
|
|
assert_eq!(loaded.plugins().enabled_plugins().len(), 0);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn deep_merge_objects_merges_nested_maps() {
|
|
|
|
|
// given
|
|
|
|
|
let mut target = JsonValue::parse(r#"{"env":{"A":"1","B":"2"},"model":"haiku"}"#)
|
|
|
|
|
.expect("target JSON should parse")
|
|
|
|
|
.as_object()
|
|
|
|
|
.expect("target should be an object")
|
|
|
|
|
.clone();
|
|
|
|
|
let source =
|
|
|
|
|
JsonValue::parse(r#"{"env":{"B":"override","C":"3"},"sandbox":{"enabled":true}}"#)
|
|
|
|
|
.expect("source JSON should parse")
|
|
|
|
|
.as_object()
|
|
|
|
|
.expect("source should be an object")
|
|
|
|
|
.clone();
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
deep_merge_objects(&mut target, &source);
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
let env = target
|
|
|
|
|
.get("env")
|
|
|
|
|
.and_then(JsonValue::as_object)
|
|
|
|
|
.expect("env should remain an object");
|
|
|
|
|
assert_eq!(env.get("A"), Some(&JsonValue::String("1".to_string())));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
env.get("B"),
|
|
|
|
|
Some(&JsonValue::String("override".to_string()))
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(env.get("C"), Some(&JsonValue::String("3".to_string())));
|
|
|
|
|
assert!(target.contains_key("sandbox"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:15:29 +00:00
|
|
|
#[test]
|
|
|
|
|
fn rejects_invalid_hook_entries_before_merge() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
let project_settings = cwd.join(".claw").join("settings.json");
|
|
|
|
|
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
|
|
|
|
|
fs::write(
|
|
|
|
|
home.join("settings.json"),
|
|
|
|
|
r#"{"hooks":{"PreToolUse":["base"]}}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write user settings");
|
|
|
|
|
fs::write(
|
|
|
|
|
&project_settings,
|
|
|
|
|
r#"{"hooks":{"PreToolUse":["project",42]}}"#,
|
|
|
|
|
)
|
|
|
|
|
.expect("write invalid project settings");
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
let error = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect_err("config should fail");
|
|
|
|
|
|
2026-04-07 15:15:42 +09:00
|
|
|
// then — config validation now catches the mixed array before the hooks parser
|
2026-04-04 15:15:29 +00:00
|
|
|
let rendered = error.to_string();
|
2026-04-07 15:15:42 +09:00
|
|
|
assert!(
|
|
|
|
|
rendered.contains("hooks.PreToolUse")
|
|
|
|
|
&& rendered.contains("must be an array of strings"),
|
|
|
|
|
"expected validation error for hooks.PreToolUse, got: {rendered}"
|
|
|
|
|
);
|
2026-04-04 15:15:29 +00:00
|
|
|
assert!(!rendered.contains("merged settings.hooks"));
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 18:10:12 +09:00
|
|
|
#[test]
|
|
|
|
|
fn permission_mode_aliases_resolve_to_expected_modes() {
|
|
|
|
|
// given / when / then
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_permission_mode_label("plan", "test").expect("plan should resolve"),
|
|
|
|
|
ResolvedPermissionMode::ReadOnly
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_permission_mode_label("acceptEdits", "test").expect("acceptEdits should resolve"),
|
|
|
|
|
ResolvedPermissionMode::WorkspaceWrite
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_permission_mode_label("dontAsk", "test").expect("dontAsk should resolve"),
|
|
|
|
|
ResolvedPermissionMode::DangerFullAccess
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn hook_config_merge_preserves_uniques() {
|
|
|
|
|
// given
|
|
|
|
|
let base = RuntimeHookConfig::new(
|
|
|
|
|
vec!["pre-a".to_string()],
|
|
|
|
|
vec!["post-a".to_string()],
|
|
|
|
|
vec!["failure-a".to_string()],
|
|
|
|
|
);
|
|
|
|
|
let overlay = RuntimeHookConfig::new(
|
|
|
|
|
vec!["pre-a".to_string(), "pre-b".to_string()],
|
|
|
|
|
vec!["post-a".to_string(), "post-b".to_string()],
|
|
|
|
|
vec!["failure-b".to_string()],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
let merged = base.merged(&overlay);
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
assert_eq!(
|
|
|
|
|
merged.pre_tool_use(),
|
|
|
|
|
&["pre-a".to_string(), "pre-b".to_string()]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
merged.post_tool_use(),
|
|
|
|
|
&["post-a".to_string(), "post-b".to_string()]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
merged.post_tool_use_failure(),
|
|
|
|
|
&["failure-a".to_string(), "failure-b".to_string()]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn plugin_state_falls_back_to_default_for_unknown_plugin() {
|
|
|
|
|
// given
|
|
|
|
|
let mut config = RuntimePluginConfig::default();
|
|
|
|
|
config.set_plugin_state("known".to_string(), true);
|
|
|
|
|
|
|
|
|
|
// when / then
|
|
|
|
|
assert!(config.state_for("known", false));
|
|
|
|
|
assert!(config.state_for("missing", true));
|
|
|
|
|
assert!(!config.state_for("missing", false));
|
|
|
|
|
}
|
2026-04-07 15:10:08 +09:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validates_unknown_top_level_keys_with_line_and_field_name() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
let user_settings = home.join("settings.json");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
&user_settings,
|
|
|
|
|
"{\n \"model\": \"opus\",\n \"telemetry\": true\n}\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("write user settings");
|
|
|
|
|
|
|
|
|
|
// when
|
2026-06-03 23:47:27 +09:00
|
|
|
let (_config, warnings) = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load_collecting_warnings()
|
|
|
|
|
.expect("unknown config keys should load with warnings");
|
2026-04-07 15:10:08 +09:00
|
|
|
|
|
|
|
|
// then
|
2026-06-03 23:47:27 +09:00
|
|
|
let rendered = warnings.join("\n");
|
2026-04-07 15:10:08 +09:00
|
|
|
assert!(
|
2026-04-08 01:45:08 +09:00
|
|
|
rendered.contains(&user_settings.display().to_string()),
|
2026-06-03 23:47:27 +09:00
|
|
|
"warning should include file path, got: {rendered}"
|
2026-04-07 15:10:08 +09:00
|
|
|
);
|
|
|
|
|
assert!(
|
2026-04-08 01:45:08 +09:00
|
|
|
rendered.contains("line 3"),
|
2026-06-03 23:47:27 +09:00
|
|
|
"warning should include line number, got: {rendered}"
|
2026-04-08 01:45:08 +09:00
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
rendered.contains("telemetry"),
|
2026-06-03 23:47:27 +09:00
|
|
|
"warning should name the offending field, got: {rendered}"
|
2026-04-07 15:10:08 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validates_deprecated_top_level_keys_with_replacement_guidance() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
let user_settings = home.join("settings.json");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
&user_settings,
|
|
|
|
|
"{\n \"model\": \"opus\",\n \"allowedTools\": [\"Read\"]\n}\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("write user settings");
|
|
|
|
|
|
|
|
|
|
// when
|
2026-06-03 23:47:27 +09:00
|
|
|
let (_config, warnings) = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load_collecting_warnings()
|
|
|
|
|
.expect("legacy unknown config keys should load with warnings");
|
2026-04-07 15:10:08 +09:00
|
|
|
|
|
|
|
|
// then
|
2026-06-03 23:47:27 +09:00
|
|
|
let rendered = warnings.join("\n");
|
2026-04-07 15:10:08 +09:00
|
|
|
assert!(
|
2026-04-08 01:45:08 +09:00
|
|
|
rendered.contains(&user_settings.display().to_string()),
|
2026-06-03 23:47:27 +09:00
|
|
|
"warning should include file path, got: {rendered}"
|
2026-04-07 15:10:08 +09:00
|
|
|
);
|
|
|
|
|
assert!(
|
2026-04-08 01:45:08 +09:00
|
|
|
rendered.contains("line 3"),
|
2026-06-03 23:47:27 +09:00
|
|
|
"warning should include line number, got: {rendered}"
|
2026-04-08 01:45:08 +09:00
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
rendered.contains("allowedTools"),
|
2026-06-03 23:47:27 +09:00
|
|
|
"warning should name the offending field, got: {rendered}"
|
2026-04-07 15:10:08 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validates_wrong_type_for_known_field_with_field_path() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
let user_settings = home.join("settings.json");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(
|
|
|
|
|
&user_settings,
|
|
|
|
|
"{\n \"hooks\": {\n \"PreToolUse\": \"not-an-array\"\n }\n}\n",
|
|
|
|
|
)
|
|
|
|
|
.expect("write user settings");
|
|
|
|
|
|
|
|
|
|
// when
|
|
|
|
|
let error = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load()
|
|
|
|
|
.expect_err("config should fail");
|
|
|
|
|
|
|
|
|
|
// then
|
|
|
|
|
let rendered = error.to_string();
|
|
|
|
|
assert!(
|
2026-04-08 01:45:08 +09:00
|
|
|
rendered.contains(&user_settings.display().to_string()),
|
|
|
|
|
"error should include file path, got: {rendered}"
|
2026-04-07 15:10:08 +09:00
|
|
|
);
|
|
|
|
|
assert!(
|
2026-04-08 01:45:08 +09:00
|
|
|
rendered.contains("hooks"),
|
|
|
|
|
"error should include field path component 'hooks', got: {rendered}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
rendered.contains("PreToolUse"),
|
2026-04-07 15:10:08 +09:00
|
|
|
"error should describe the type mismatch, got: {rendered}"
|
|
|
|
|
);
|
2026-04-08 01:45:08 +09:00
|
|
|
assert!(
|
|
|
|
|
rendered.contains("array"),
|
|
|
|
|
"error should describe the expected type, got: {rendered}"
|
|
|
|
|
);
|
2026-04-07 15:10:08 +09:00
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unknown_top_level_key_suggests_closest_match() {
|
|
|
|
|
// given
|
|
|
|
|
let root = temp_dir();
|
|
|
|
|
let cwd = root.join("project");
|
|
|
|
|
let home = root.join("home").join(".claw");
|
|
|
|
|
let user_settings = home.join("settings.json");
|
|
|
|
|
fs::create_dir_all(&home).expect("home config dir");
|
|
|
|
|
fs::create_dir_all(&cwd).expect("project dir");
|
|
|
|
|
fs::write(&user_settings, "{\n \"modle\": \"opus\"\n}\n").expect("write user settings");
|
|
|
|
|
|
|
|
|
|
// when
|
2026-06-03 23:47:27 +09:00
|
|
|
let (_config, warnings) = ConfigLoader::new(&cwd, &home)
|
|
|
|
|
.load_collecting_warnings()
|
|
|
|
|
.expect("unknown config keys should load with warnings");
|
2026-04-07 15:10:08 +09:00
|
|
|
|
|
|
|
|
// then
|
2026-06-03 23:47:27 +09:00
|
|
|
let rendered = warnings.join("\n");
|
2026-04-07 15:10:08 +09:00
|
|
|
assert!(
|
2026-04-08 01:45:08 +09:00
|
|
|
rendered.contains("modle"),
|
2026-06-03 23:47:27 +09:00
|
|
|
"warning should name the offending field, got: {rendered}"
|
2026-04-07 15:10:08 +09:00
|
|
|
);
|
|
|
|
|
assert!(
|
2026-04-08 01:45:08 +09:00
|
|
|
rendered.contains("model"),
|
2026-06-03 23:47:27 +09:00
|
|
|
"warning should suggest the closest known key, got: {rendered}"
|
2026-04-07 15:10:08 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
|
|
|
}
|
2026-04-01 03:55:00 +00:00
|
|
|
}
|