Report structured config file load statuses
This commit is contained in:
parent
b64df99134
commit
9c3d52d0cd
|
|
@ -1,22 +1,7 @@
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::BTreeMap;
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
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();
|
|
||||||
|
|
||||||
fn emit_config_warning_once(warning: &str) {
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::json::JsonValue;
|
use crate::json::JsonValue;
|
||||||
use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
|
use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
|
||||||
|
|
@ -55,6 +40,45 @@ pub struct RuntimeConfig {
|
||||||
feature_config: RuntimeFeatureConfig,
|
feature_config: RuntimeFeatureConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 a single 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort inspection of the full config discovery/load pipeline.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ConfigInspection {
|
||||||
|
pub files: Vec<ConfigFileReport>,
|
||||||
|
pub runtime_config: Option<RuntimeConfig>,
|
||||||
|
pub load_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parsed plugin-related settings extracted from runtime config.
|
/// Parsed plugin-related settings extracted from runtime config.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct RuntimePluginConfig {
|
pub struct RuntimePluginConfig {
|
||||||
|
|
@ -105,10 +129,6 @@ pub struct RuntimePermissionRuleConfig {
|
||||||
allow: Vec<String>,
|
allow: Vec<String>,
|
||||||
deny: Vec<String>,
|
deny: Vec<String>,
|
||||||
ask: Vec<String>,
|
ask: Vec<String>,
|
||||||
/// #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>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collection of configured MCP servers after scope-aware merging.
|
/// Collection of configured MCP servers after scope-aware merging.
|
||||||
|
|
@ -120,7 +140,6 @@ pub struct McpConfigCollection {
|
||||||
/// MCP server config paired with the scope that defined it.
|
/// MCP server config paired with the scope that defined it.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ScopedMcpServerConfig {
|
pub struct ScopedMcpServerConfig {
|
||||||
pub required: bool,
|
|
||||||
pub scope: ConfigSource,
|
pub scope: ConfigSource,
|
||||||
pub config: McpServerConfig,
|
pub config: McpServerConfig,
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +315,7 @@ impl ConfigLoader {
|
||||||
|
|
||||||
for entry in self.discover() {
|
for entry in self.discover() {
|
||||||
crate::config_validate::check_unsupported_format(&entry.path)?;
|
crate::config_validate::check_unsupported_format(&entry.path)?;
|
||||||
let Some(parsed) = read_optional_json_object(&entry.path)? else {
|
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let validation = crate::config_validate::validate_config_file(
|
let validation = crate::config_validate::validate_config_file(
|
||||||
|
|
@ -316,33 +335,189 @@ impl ConfigLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
for warning in &all_warnings {
|
for warning in &all_warnings {
|
||||||
emit_config_warning_once(&warning.to_string());
|
eprintln!("warning: {warning}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let merged_value = JsonValue::Object(merged.clone());
|
build_runtime_config(merged, loaded_entries, mcp_servers)
|
||||||
|
|
||||||
let feature_config = RuntimeFeatureConfig {
|
|
||||||
hooks: parse_optional_hooks_config(&merged_value)?,
|
|
||||||
plugins: parse_optional_plugin_config(&merged_value)?,
|
|
||||||
mcp: McpConfigCollection {
|
|
||||||
servers: mcp_servers,
|
|
||||||
},
|
|
||||||
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)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(RuntimeConfig {
|
|
||||||
merged,
|
|
||||||
loaded_entries,
|
|
||||||
feature_config,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inspect discovered files and return per-file statuses without aborting
|
||||||
|
/// the whole report on the first missing/skipped/invalid file.
|
||||||
|
#[must_use]
|
||||||
|
pub fn inspect(&self) -> ConfigInspection {
|
||||||
|
let mut merged = BTreeMap::new();
|
||||||
|
let mut loaded_entries = Vec::new();
|
||||||
|
let mut mcp_servers = BTreeMap::new();
|
||||||
|
let mut all_warnings = Vec::new();
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut first_error = None;
|
||||||
|
|
||||||
|
for entry in self.discover() {
|
||||||
|
if let Err(error) = crate::config_validate::check_unsupported_format(&entry.path) {
|
||||||
|
let detail = error.to_string();
|
||||||
|
first_error.get_or_insert_with(|| detail.clone());
|
||||||
|
files.push(ConfigFileReport {
|
||||||
|
entry,
|
||||||
|
loaded: false,
|
||||||
|
status: ConfigFileStatus::LoadError,
|
||||||
|
reason: Some("unsupported_format".to_string()),
|
||||||
|
detail: Some(detail),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = match read_optional_json_object(&entry.path) {
|
||||||
|
Ok(OptionalConfigFile::Loaded(parsed)) => parsed,
|
||||||
|
Ok(OptionalConfigFile::NotFound) => {
|
||||||
|
files.push(ConfigFileReport {
|
||||||
|
entry,
|
||||||
|
loaded: false,
|
||||||
|
status: ConfigFileStatus::NotFound,
|
||||||
|
reason: Some("not_found".to_string()),
|
||||||
|
detail: None,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(OptionalConfigFile::Skipped { reason, detail }) => {
|
||||||
|
files.push(ConfigFileReport {
|
||||||
|
entry,
|
||||||
|
loaded: false,
|
||||||
|
status: ConfigFileStatus::Skipped,
|
||||||
|
reason: Some(reason),
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let detail = error.to_string();
|
||||||
|
first_error.get_or_insert_with(|| detail.clone());
|
||||||
|
let reason = match &error {
|
||||||
|
ConfigError::Io(io_error)
|
||||||
|
if io_error.kind() == std::io::ErrorKind::PermissionDenied =>
|
||||||
|
{
|
||||||
|
"permission_denied"
|
||||||
|
}
|
||||||
|
ConfigError::Io(_) => "io_error",
|
||||||
|
ConfigError::Parse(_) => "parse_error",
|
||||||
|
};
|
||||||
|
files.push(ConfigFileReport {
|
||||||
|
entry,
|
||||||
|
loaded: false,
|
||||||
|
status: ConfigFileStatus::LoadError,
|
||||||
|
reason: Some(reason.to_string()),
|
||||||
|
detail: Some(detail),
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
first_error.get_or_insert_with(|| detail.clone());
|
||||||
|
files.push(ConfigFileReport {
|
||||||
|
entry,
|
||||||
|
loaded: false,
|
||||||
|
status: ConfigFileStatus::LoadError,
|
||||||
|
reason: Some("validation_error".to_string()),
|
||||||
|
detail: Some(detail),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
all_warnings.extend(validation.warnings);
|
||||||
|
|
||||||
|
if let Err(error) = validate_optional_hooks_config(&parsed.object, &entry.path) {
|
||||||
|
let detail = error.to_string();
|
||||||
|
first_error.get_or_insert_with(|| detail.clone());
|
||||||
|
files.push(ConfigFileReport {
|
||||||
|
entry,
|
||||||
|
loaded: false,
|
||||||
|
status: ConfigFileStatus::LoadError,
|
||||||
|
reason: Some("validation_error".to_string()),
|
||||||
|
detail: Some(detail),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) =
|
||||||
|
merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)
|
||||||
|
{
|
||||||
|
let detail = error.to_string();
|
||||||
|
first_error.get_or_insert_with(|| detail.clone());
|
||||||
|
files.push(ConfigFileReport {
|
||||||
|
entry,
|
||||||
|
loaded: false,
|
||||||
|
status: ConfigFileStatus::LoadError,
|
||||||
|
reason: Some("parse_error".to_string()),
|
||||||
|
detail: Some(detail),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
deep_merge_objects(&mut merged, &parsed.object);
|
||||||
|
loaded_entries.push(entry.clone());
|
||||||
|
files.push(ConfigFileReport {
|
||||||
|
entry,
|
||||||
|
loaded: true,
|
||||||
|
status: ConfigFileStatus::Loaded,
|
||||||
|
reason: None,
|
||||||
|
detail: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for warning in &all_warnings {
|
||||||
|
eprintln!("warning: {warning}");
|
||||||
|
}
|
||||||
|
|
||||||
|
match build_runtime_config(merged, loaded_entries, mcp_servers) {
|
||||||
|
Ok(runtime_config) => ConfigInspection {
|
||||||
|
files,
|
||||||
|
runtime_config: Some(runtime_config),
|
||||||
|
load_error: first_error,
|
||||||
|
},
|
||||||
|
Err(error) => {
|
||||||
|
first_error.get_or_insert_with(|| error.to_string());
|
||||||
|
ConfigInspection {
|
||||||
|
files,
|
||||||
|
runtime_config: None,
|
||||||
|
load_error: first_error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_runtime_config(
|
||||||
|
merged: BTreeMap<String, JsonValue>,
|
||||||
|
loaded_entries: Vec<ConfigEntry>,
|
||||||
|
mcp_servers: BTreeMap<String, ScopedMcpServerConfig>,
|
||||||
|
) -> 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)?,
|
||||||
|
mcp: McpConfigCollection {
|
||||||
|
servers: mcp_servers,
|
||||||
|
},
|
||||||
|
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)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RuntimeConfig {
|
||||||
|
merged,
|
||||||
|
loaded_entries,
|
||||||
|
feature_config,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeConfig {
|
impl RuntimeConfig {
|
||||||
|
|
@ -434,17 +609,6 @@ impl RuntimeConfig {
|
||||||
pub fn trusted_roots(&self) -> &[String] {
|
pub fn trusted_roots(&self) -> &[String] {
|
||||||
&self.feature_config.trusted_roots
|
&self.feature_config.trusted_roots
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeFeatureConfig {
|
impl RuntimeFeatureConfig {
|
||||||
|
|
@ -514,22 +678,6 @@ impl RuntimeFeatureConfig {
|
||||||
pub fn trusted_roots(&self) -> &[String] {
|
pub fn trusted_roots(&self) -> &[String] {
|
||||||
&self.trusted_roots
|
&self.trusted_roots
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderFallbackConfig {
|
impl ProviderFallbackConfig {
|
||||||
|
|
@ -611,104 +759,6 @@ pub fn default_config_home() -> PathBuf {
|
||||||
.unwrap_or_else(|| PathBuf::from(".claw"))
|
.unwrap_or_else(|| PathBuf::from(".claw"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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();
|
|
||||||
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()),
|
|
||||||
);
|
|
||||||
if let Some(base_url) = base_url {
|
|
||||||
provider.insert(
|
|
||||||
"baseUrl".to_string(),
|
|
||||||
serde_json::Value::String(base_url.to_string()),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
provider.remove("baseUrl");
|
|
||||||
}
|
|
||||||
root.insert("provider".to_string(), serde_json::Value::Object(provider));
|
|
||||||
if let Some(model) = model {
|
|
||||||
root.insert(
|
|
||||||
"model".to_string(),
|
|
||||||
serde_json::Value::String(model.to_string()),
|
|
||||||
);
|
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RuntimeHookConfig {
|
impl RuntimeHookConfig {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
|
@ -757,18 +807,8 @@ impl RuntimeHookConfig {
|
||||||
|
|
||||||
impl RuntimePermissionRuleConfig {
|
impl RuntimePermissionRuleConfig {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(allow: Vec<String>, deny: Vec<String>, ask: Vec<String>) -> Self {
|
||||||
allow: Vec<String>,
|
Self { allow, deny, ask }
|
||||||
deny: Vec<String>,
|
|
||||||
ask: Vec<String>,
|
|
||||||
denied_tools: Vec<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
allow,
|
|
||||||
deny,
|
|
||||||
ask,
|
|
||||||
denied_tools,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -785,11 +825,6 @@ impl RuntimePermissionRuleConfig {
|
||||||
pub fn ask(&self) -> &[String] {
|
pub fn ask(&self) -> &[String] {
|
||||||
&self.ask
|
&self.ask
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn denied_tools(&self) -> &[String] {
|
|
||||||
&self.denied_tools
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpConfigCollection {
|
impl McpConfigCollection {
|
||||||
|
|
@ -831,16 +866,27 @@ struct ParsedConfigFile {
|
||||||
source: String,
|
source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_optional_json_object(path: &Path) -> Result<Option<ParsedConfigFile>, ConfigError> {
|
enum OptionalConfigFile {
|
||||||
|
Loaded(ParsedConfigFile),
|
||||||
|
NotFound,
|
||||||
|
Skipped {
|
||||||
|
reason: String,
|
||||||
|
detail: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_optional_json_object(path: &Path) -> Result<OptionalConfigFile, ConfigError> {
|
||||||
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claw.json");
|
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claw.json");
|
||||||
let contents = match fs::read_to_string(path) {
|
let contents = match fs::read_to_string(path) {
|
||||||
Ok(contents) => contents,
|
Ok(contents) => contents,
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(OptionalConfigFile::NotFound);
|
||||||
|
}
|
||||||
Err(error) => return Err(ConfigError::Io(error)),
|
Err(error) => return Err(ConfigError::Io(error)),
|
||||||
};
|
};
|
||||||
|
|
||||||
if contents.trim().is_empty() {
|
if contents.trim().is_empty() {
|
||||||
return Ok(Some(ParsedConfigFile {
|
return Ok(OptionalConfigFile::Loaded(ParsedConfigFile {
|
||||||
object: BTreeMap::new(),
|
object: BTreeMap::new(),
|
||||||
source: contents,
|
source: contents,
|
||||||
}));
|
}));
|
||||||
|
|
@ -848,19 +894,30 @@ fn read_optional_json_object(path: &Path) -> Result<Option<ParsedConfigFile>, Co
|
||||||
|
|
||||||
let parsed = match JsonValue::parse(&contents) {
|
let parsed = match JsonValue::parse(&contents) {
|
||||||
Ok(parsed) => parsed,
|
Ok(parsed) => parsed,
|
||||||
Err(_error) if is_legacy_config => return Ok(None),
|
Err(error) if is_legacy_config => {
|
||||||
|
return Ok(OptionalConfigFile::Skipped {
|
||||||
|
reason: "legacy_invalid_json".to_string(),
|
||||||
|
detail: Some(format!("{}: {error}", path.display())),
|
||||||
|
});
|
||||||
|
}
|
||||||
Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
|
Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
|
||||||
};
|
};
|
||||||
let Some(object) = parsed.as_object() else {
|
let Some(object) = parsed.as_object() else {
|
||||||
if is_legacy_config {
|
if is_legacy_config {
|
||||||
return Ok(None);
|
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()
|
||||||
|
)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Err(ConfigError::Parse(format!(
|
return Err(ConfigError::Parse(format!(
|
||||||
"{}: top-level settings value must be a JSON object",
|
"{}: top-level settings value must be a JSON object",
|
||||||
path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
Ok(Some(ParsedConfigFile {
|
Ok(OptionalConfigFile::Loaded(ParsedConfigFile {
|
||||||
object: object.clone(),
|
object: object.clone(),
|
||||||
source: contents,
|
source: contents,
|
||||||
}))
|
}))
|
||||||
|
|
@ -885,12 +942,6 @@ fn merge_mcp_servers(
|
||||||
target.insert(
|
target.insert(
|
||||||
name.clone(),
|
name.clone(),
|
||||||
ScopedMcpServerConfig {
|
ScopedMcpServerConfig {
|
||||||
required: optional_bool(
|
|
||||||
expect_object(value, &format!("{}: mcpServers.{name}", path.display()))?,
|
|
||||||
"required",
|
|
||||||
&format!("{}: mcpServers.{name}", path.display()),
|
|
||||||
)?
|
|
||||||
.unwrap_or(false),
|
|
||||||
scope: source,
|
scope: source,
|
||||||
config: parsed,
|
config: parsed,
|
||||||
},
|
},
|
||||||
|
|
@ -960,12 +1011,6 @@ fn parse_optional_permission_rules(
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
ask: optional_string_array(permissions, "ask", "merged settings.permissions")?
|
ask: optional_string_array(permissions, "ask", "merged settings.permissions")?
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
denied_tools: optional_string_array(
|
|
||||||
permissions,
|
|
||||||
"deniedTools",
|
|
||||||
"merged settings.permissions",
|
|
||||||
)?
|
|
||||||
.unwrap_or_default(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1416,9 +1461,9 @@ fn push_unique(target: &mut Vec<String>, value: String) {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource,
|
deep_merge_objects, parse_permission_mode_label, ConfigFileStatus, ConfigLoader,
|
||||||
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig,
|
ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeHookConfig,
|
||||||
RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
};
|
};
|
||||||
use crate::json::JsonValue;
|
use crate::json::JsonValue;
|
||||||
use crate::sandbox::FilesystemIsolationMode;
|
use crate::sandbox::FilesystemIsolationMode;
|
||||||
|
|
@ -1674,51 +1719,6 @@ mod tests {
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn trusted_roots_default_is_empty_when_unset() {
|
fn trusted_roots_default_is_empty_when_unset() {
|
||||||
// given
|
// given
|
||||||
|
|
@ -1755,8 +1755,7 @@ mod tests {
|
||||||
"stdio-server": {
|
"stdio-server": {
|
||||||
"command": "uvx",
|
"command": "uvx",
|
||||||
"args": ["mcp-server"],
|
"args": ["mcp-server"],
|
||||||
"env": {"TOKEN": "secret"},
|
"env": {"TOKEN": "secret"}
|
||||||
"required": true
|
|
||||||
},
|
},
|
||||||
"remote-server": {
|
"remote-server": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
|
|
@ -1805,7 +1804,6 @@ mod tests {
|
||||||
.get("stdio-server")
|
.get("stdio-server")
|
||||||
.expect("stdio server should exist");
|
.expect("stdio server should exist");
|
||||||
assert_eq!(stdio_server.scope, ConfigSource::User);
|
assert_eq!(stdio_server.scope, ConfigSource::User);
|
||||||
assert!(stdio_server.required);
|
|
||||||
assert_eq!(stdio_server.transport(), McpTransport::Stdio);
|
assert_eq!(stdio_server.transport(), McpTransport::Stdio);
|
||||||
|
|
||||||
let remote_server = loaded
|
let remote_server = loaded
|
||||||
|
|
@ -1813,7 +1811,6 @@ mod tests {
|
||||||
.get("remote-server")
|
.get("remote-server")
|
||||||
.expect("remote server should exist");
|
.expect("remote server should exist");
|
||||||
assert_eq!(remote_server.scope, ConfigSource::Local);
|
assert_eq!(remote_server.scope, ConfigSource::Local);
|
||||||
assert!(!remote_server.required);
|
|
||||||
assert_eq!(remote_server.transport(), McpTransport::Ws);
|
assert_eq!(remote_server.transport(), McpTransport::Ws);
|
||||||
match &remote_server.config {
|
match &remote_server.config {
|
||||||
McpServerConfig::Ws(config) => {
|
McpServerConfig::Ws(config) => {
|
||||||
|
|
@ -2055,6 +2052,47 @@ mod tests {
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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 claw dir");
|
||||||
|
fs::create_dir_all(&home).expect("home dir");
|
||||||
|
fs::write(cwd.join(".claw.json"), "{not json").expect("legacy config");
|
||||||
|
fs::write(cwd.join(".claw/settings.json"), r#"{"model":"opus"}"#)
|
||||||
|
.expect("project settings");
|
||||||
|
|
||||||
|
let inspection = ConfigLoader::new(&cwd, &home).inspect();
|
||||||
|
assert!(inspection.load_error.is_none());
|
||||||
|
assert!(inspection.runtime_config.is_some());
|
||||||
|
|
||||||
|
let loaded = inspection
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.find(|file| file.loaded)
|
||||||
|
.expect("loaded file");
|
||||||
|
assert_eq!(loaded.status, ConfigFileStatus::Loaded);
|
||||||
|
assert_eq!(loaded.reason, 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]
|
#[test]
|
||||||
fn deep_merge_objects_merges_nested_maps() {
|
fn deep_merge_objects_merges_nested_maps() {
|
||||||
// given
|
// given
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue