use crate::config::{atomic_write, get_app_config_dir, home_dir};
use crate::error::AppError;
use crate::provider::OpenClawProviderConfig;
use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir};
use chrono::Local;
use indexmap::IndexMap;
use json_five::rt::parser::{
from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair,
JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue,
KeyValuePairContext as RtKeyValuePairContext,
};
use serde::{de, Deserialize, Deserializer, Serialize};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
const OPENCLAW_DEFAULT_SOURCE: &str =
"{\n \"models\": {\n \"mode\": \"merge\",\n \"providers\": {}\n }\n}\n";
pub fn get_openclaw_dir() -> PathBuf {
if let Some(override_dir) = get_openclaw_override_dir() {
return override_dir;
}
home_dir()
.map(|home| home.join(".openclaw"))
.unwrap_or_else(|| PathBuf::from(".openclaw"))
}
pub fn get_openclaw_config_path() -> PathBuf {
get_openclaw_dir().join("openclaw.json")
}
fn default_config() -> Value {
json!({
"models": {
"mode": "merge",
"providers": {}
}
})
}
fn openclaw_write_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawHealthWarning {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawWriteOutcome {
#[serde(skip_serializing_if = "Option::is_none")]
pub backup_path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<OpenClawHealthWarning>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct OpenClawDefaultModel {
pub primary: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub fallbacks: Vec<String>,
#[serde(flatten, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
#[derive(Deserialize)]
struct OpenClawDefaultModelObject {
#[serde(default)]
primary: String,
#[serde(default, deserialize_with = "deserialize_openclaw_model_fallbacks")]
fallbacks: Vec<String>,
#[serde(flatten, default)]
extra: HashMap<String, Value>,
}
impl<'de> Deserialize<'de> for OpenClawDefaultModel {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::String(primary) => Ok(Self {
primary,
fallbacks: Vec::new(),
extra: HashMap::new(),
}),
Value::Object(_) => {
let parsed = serde_json::from_value::<OpenClawDefaultModelObject>(value)
.map_err(de::Error::custom)?;
Ok(Self {
primary: parsed.primary,
fallbacks: parsed.fallbacks,
extra: parsed.extra,
})
}
Value::Null => Ok(Self {
primary: String::new(),
fallbacks: Vec::new(),
extra: HashMap::new(),
}),
other => Err(de::Error::custom(format!(
"expected string or object for OpenClaw default model, got {other}"
))),
}
}
}
fn deserialize_openclaw_model_fallbacks<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(Vec::new()),
Some(Value::String(value)) => Ok(vec![value]),
Some(Value::Array(values)) => values
.into_iter()
.map(|value| match value {
Value::String(value) => Ok(value),
other => Err(de::Error::custom(format!(
"expected string fallback model reference, got {other}"
))),
})
.collect(),
Some(other) => Err(de::Error::custom(format!(
"expected array, string, or null for OpenClaw fallback models, got {other}"
))),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct OpenClawModelCatalogEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct OpenClawAgentsDefaults {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<OpenClawDefaultModel>,
#[serde(skip_serializing_if = "Option::is_none")]
pub models: Option<HashMap<String, OpenClawModelCatalogEntry>>,
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct OpenClawEnvConfig {
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub vars: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct OpenClawToolsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<String>,
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
pub fn read_openclaw_config() -> Result<Value, AppError> {
let path = get_openclaw_config_path();
if !path.exists() {
return Ok(default_config());
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
json5::from_str(&content)
.map_err(|e| AppError::Config(format!("Failed to parse OpenClaw config as JSON5: {e}")))
}
pub fn read_openclaw_config_source() -> Result<Option<String>, AppError> {
let path = get_openclaw_config_path();
if !path.exists() {
return Ok(None);
}
fs::read_to_string(&path)
.map(Some)
.map_err(|e| AppError::io(&path, e))
}
pub fn write_openclaw_config_source(source: &str) -> Result<(), AppError> {
let path = get_openclaw_config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
atomic_write(&path, source.as_bytes())
}
pub fn scan_openclaw_config_health() -> Result<Vec<OpenClawHealthWarning>, AppError> {
let path = get_openclaw_config_path();
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
match json5::from_str::<Value>(&content) {
Ok(config) => Ok(scan_openclaw_health_from_value(&config)),
Err(err) => Ok(vec![OpenClawHealthWarning {
code: "config_parse_failed".to_string(),
message: format!("OpenClaw config could not be parsed as JSON5: {err}"),
path: Some(path.display().to_string()),
}]),
}
}
struct OpenClawConfigDocument {
path: PathBuf,
original_source: Option<String>,
text: RtJSONText,
}
impl OpenClawConfigDocument {
fn load() -> Result<Self, AppError> {
let path = get_openclaw_config_path();
let original_source = if path.exists() {
Some(fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?)
} else {
None
};
let source = original_source
.clone()
.unwrap_or_else(|| OPENCLAW_DEFAULT_SOURCE.to_string());
let text = rt_from_str(&source).map_err(|e| {
AppError::Config(format!(
"Failed to parse OpenClaw config as round-trip JSON5 document: {}",
e.message
))
})?;
Ok(Self {
path,
original_source,
text,
})
}
fn set_root_section(&mut self, key: &str, value: &Value) -> Result<(), AppError> {
let RtJSONValue::JSONObject {
key_value_pairs,
context,
} = &mut self.text.value
else {
return Err(AppError::Config(
"OpenClaw config root must be a JSON5 object".to_string(),
));
};
if key_value_pairs.is_empty()
&& context
.as_ref()
.map(|ctx| ctx.wsc.0.is_empty())
.unwrap_or(true)
{
*context = Some(RtJSONObjectContext {
wsc: ("\n ".to_string(),),
});
}
let leading_ws = context
.as_ref()
.map(|ctx| ctx.wsc.0.clone())
.unwrap_or_default();
let entry_separator_ws = derive_entry_separator(&leading_ws);
let child_indent = extract_trailing_indent(&leading_ws);
let new_value = value_to_rt_value(key, value, &child_indent)?;
if let Some(existing) = key_value_pairs
.iter_mut()
.find(|pair| json5_key_name(&pair.key).as_deref() == Some(key))
{
existing.value = new_value;
return Ok(());
}
let new_pair = if let Some(last_pair) = key_value_pairs.last_mut() {
let last_ctx = ensure_kvp_context(last_pair);
let closing_ws = if let Some(after_comma) = last_ctx.wsc.3.clone() {
last_ctx.wsc.3 = Some(entry_separator_ws.clone());
after_comma
} else {
let closing_ws = std::mem::take(&mut last_ctx.wsc.2);
last_ctx.wsc.3 = Some(entry_separator_ws.clone());
closing_ws
};
make_root_pair(key, new_value, closing_ws)
} else {
make_root_pair(
key,
new_value,
derive_closing_ws_from_separator(&leading_ws),
)
};
key_value_pairs.push(new_pair);
Ok(())
}
fn save(self) -> Result<OpenClawWriteOutcome, AppError> {
let _guard = openclaw_write_lock().lock()?;
let current_source = if self.path.exists() {
Some(fs::read_to_string(&self.path).map_err(|e| AppError::io(&self.path, e))?)
} else {
None
};
if current_source != self.original_source {
return Err(AppError::Config(
"OpenClaw config changed on disk. Please reload and try again.".to_string(),
));
}
let next_source = self.text.to_string();
if current_source.as_deref() == Some(next_source.as_str()) {
let warnings = scan_openclaw_health_from_value(
&json5::from_str::<Value>(&next_source).map_err(|e| {
AppError::Config(format!(
"Failed to parse unchanged OpenClaw config as JSON5: {e}"
))
})?,
);
return Ok(OpenClawWriteOutcome {
backup_path: None,
warnings,
});
}
let backup_path = current_source
.as_ref()
.map(|source| create_openclaw_backup(source))
.transpose()?
.map(|path| path.display().to_string());
atomic_write(&self.path, next_source.as_bytes())?;
let warnings = scan_openclaw_health_from_value(
&json5::from_str::<Value>(&next_source).map_err(|e| {
AppError::Config(format!(
"Failed to parse newly written OpenClaw config as JSON5: {e}"
))
})?,
);
log::debug!("OpenClaw config written to {:?}", self.path);
Ok(OpenClawWriteOutcome {
backup_path,
warnings,
})
}
}
fn write_root_section(section: &str, value: &Value) -> Result<OpenClawWriteOutcome, AppError> {
let mut document = OpenClawConfigDocument::load()?;
document.set_root_section(section, value)?;
document.save()
}
fn write_root_sections(sections: &[(&str, Value)]) -> Result<OpenClawWriteOutcome, AppError> {
let mut document = OpenClawConfigDocument::load()?;
for (section, value) in sections {
document.set_root_section(section, value)?;
}
document.save()
}
fn create_openclaw_backup(source: &str) -> Result<PathBuf, AppError> {
let backup_dir = get_app_config_dir().join("backups").join("openclaw");
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let base_id = format!("openclaw_{}", Local::now().format("%Y%m%d_%H%M%S"));
let mut filename = format!("{base_id}.json5");
let mut backup_path = backup_dir.join(&filename);
let mut counter = 1;
while backup_path.exists() {
filename = format!("{base_id}_{counter}.json5");
backup_path = backup_dir.join(&filename);
counter += 1;
}
atomic_write(&backup_path, source.as_bytes())?;
cleanup_openclaw_backups(&backup_dir)?;
Ok(backup_path)
}
fn cleanup_openclaw_backups(dir: &Path) -> Result<(), AppError> {
let retain = effective_backup_retain_count();
let mut entries = fs::read_dir(dir)
.map_err(|e| AppError::io(dir, e))?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "json5" || ext == "json")
.unwrap_or(false)
})
.collect::<Vec<_>>();
if entries.len() <= retain {
return Ok(());
}
entries.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
let remove_count = entries.len().saturating_sub(retain);
for entry in entries.into_iter().take(remove_count) {
if let Err(err) = fs::remove_file(entry.path()) {
log::warn!(
"Failed to remove old OpenClaw config backup {}: {err}",
entry.path().display()
);
}
}
Ok(())
}
fn ensure_object(value: &mut Value) -> &mut Map<String, Value> {
if !value.is_object() {
*value = Value::Object(Map::new());
}
value
.as_object_mut()
.expect("value should be object after normalization")
}
fn ensure_kvp_context(pair: &mut RtJSONKeyValuePair) -> &mut RtKeyValuePairContext {
pair.context.get_or_insert_with(|| RtKeyValuePairContext {
wsc: (String::new(), " ".to_string(), String::new(), None),
})
}
fn extract_trailing_indent(separator_ws: &str) -> String {
separator_ws
.rsplit_once('\n')
.map(|(_, tail)| tail.to_string())
.unwrap_or_default()
}
fn derive_closing_ws_from_separator(separator_ws: &str) -> String {
let Some((prefix, indent)) = separator_ws.rsplit_once('\n') else {
return String::new();
};
let reduced_indent = if indent.ends_with('\t') {
&indent[..indent.len().saturating_sub(1)]
} else if indent.ends_with(" ") {
&indent[..indent.len().saturating_sub(2)]
} else if indent.ends_with(' ') {
&indent[..indent.len().saturating_sub(1)]
} else {
indent
};
format!("{prefix}\n{reduced_indent}")
}
fn derive_entry_separator(leading_ws: &str) -> String {
if leading_ws.is_empty() {
return String::new();
}
if leading_ws.contains('\n') {
return format!("\n{}", extract_trailing_indent(leading_ws));
}
String::new()
}
fn serialize_section_value(_section: &str, value: &Value) -> Result<String, AppError> {
serde_json::to_string_pretty(value).map_err(|source| AppError::JsonSerialize { source })
}
fn value_to_rt_value(
section: &str,
value: &Value,
parent_indent: &str,
) -> Result<RtJSONValue, AppError> {
let source = serialize_section_value(section, value)?;
let adjusted = reindent_json5_block(&source, parent_indent);
let text = rt_from_str(&adjusted).map_err(|e| {
AppError::Config(format!(
"Failed to parse generated JSON5 section: {}",
e.message
))
})?;
Ok(text.value)
}
fn reindent_json5_block(source: &str, parent_indent: &str) -> String {
let normalized = normalize_json_five_output(source);
if parent_indent.is_empty() || !normalized.contains('\n') {
return normalized;
}
let mut lines = normalized.lines();
let Some(first_line) = lines.next() else {
return String::new();
};
let mut result = String::from(first_line);
for line in lines {
result.push('\n');
result.push_str(parent_indent);
result.push_str(line);
}
result
}
fn normalize_json_five_output(source: &str) -> String {
source.replace("\\/", "/")
}
fn make_root_pair(key: &str, value: RtJSONValue, closing_ws: String) -> RtJSONKeyValuePair {
RtJSONKeyValuePair {
key: make_json5_key(key),
value,
context: Some(RtKeyValuePairContext {
wsc: (String::new(), " ".to_string(), closing_ws, None),
}),
}
}
fn make_json5_key(key: &str) -> RtJSONValue {
RtJSONValue::DoubleQuotedString(key.to_string())
}
fn json5_key_name(key: &RtJSONValue) -> Option<&str> {
match key {
RtJSONValue::Identifier(name)
| RtJSONValue::DoubleQuotedString(name)
| RtJSONValue::SingleQuotedString(name) => Some(name),
_ => None,
}
}
fn warning(code: &str, message: impl Into<String>, path: Option<&str>) -> OpenClawHealthWarning {
OpenClawHealthWarning {
code: code.to_string(),
message: message.into(),
path: path.map(|value| value.to_string()),
}
}
fn scan_openclaw_health_from_value(config: &Value) -> Vec<OpenClawHealthWarning> {
let mut warnings = Vec::new();
if let Some(profile) = config
.get("tools")
.and_then(|tools| tools.get("profile"))
.and_then(Value::as_str)
{
const OPENCLAW_TOOLS_PROFILES: &[&str] = &["minimal", "coding", "messaging", "full"];
if !OPENCLAW_TOOLS_PROFILES.contains(&profile) {
warnings.push(warning(
"invalid_tools_profile",
format!("tools.profile uses unsupported value '{profile}'."),
Some("tools.profile"),
));
}
}
if config
.get("agents")
.and_then(|agents| agents.get("defaults"))
.and_then(|defaults| defaults.get("timeout"))
.is_some()
{
warnings.push(warning(
"legacy_agents_timeout",
"agents.defaults.timeout is deprecated; use agents.defaults.timeoutSeconds.",
Some("agents.defaults.timeout"),
));
}
if let Some(value) = config.get("env").and_then(|env| env.get("vars")) {
if !value.is_object() {
warnings.push(warning(
"stringified_env_vars",
"env.vars should be an object. The current value looks stringified or malformed.",
Some("env.vars"),
));
}
}
if let Some(value) = config.get("env").and_then(|env| env.get("shellEnv")) {
if !value.is_object() {
warnings.push(warning(
"stringified_env_shell_env",
"env.shellEnv should be an object. The current value looks stringified or malformed.",
Some("env.shellEnv"),
));
}
}
warnings
}
fn remove_legacy_timeout(defaults_value: &mut Value) {
if let Some(defaults_obj) = defaults_value.as_object_mut() {
defaults_obj.remove("timeout");
}
}
fn remove_provider_model_catalog_entries(config: &mut Value, provider_id: &str) -> bool {
let Some(catalog) = config
.get_mut("agents")
.and_then(|agents| agents.get_mut("defaults"))
.and_then(|defaults| defaults.get_mut("models"))
.and_then(Value::as_object_mut)
else {
return false;
};
let prefix = format!("{provider_id}/");
let original_len = catalog.len();
catalog.retain(|model_ref, _| !model_ref.starts_with(&prefix));
catalog.len() != original_len
}
fn default_model_from_config(config: &Value) -> Result<Option<OpenClawDefaultModel>, AppError> {
let Some(model_value) = config
.get("agents")
.and_then(|agents| agents.get("defaults"))
.and_then(|defaults| defaults.get("model"))
else {
return Ok(None);
};
let model = serde_json::from_value(model_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.model: {e}")))?;
Ok(Some(model))
}
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
let config = read_openclaw_config()?;
Ok(config
.get("models")
.and_then(|models| models.get("providers"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default())
}
pub fn get_provider(id: &str) -> Result<Option<Value>, AppError> {
Ok(get_providers()?.get(id).cloned())
}
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
let config = read_openclaw_config()?;
Ok(config
.get("mcp")
.and_then(|mcp| mcp.get("servers"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default())
}
pub fn set_mcp_server(id: &str, server_config: Value) -> Result<OpenClawWriteOutcome, AppError> {
let mut full_config = read_openclaw_config()?;
{
let root = ensure_object(&mut full_config);
let mcp = root
.entry("mcp".to_string())
.or_insert_with(|| json!({ "servers": {} }));
let servers = ensure_object(mcp)
.entry("servers".to_string())
.or_insert_with(|| Value::Object(Map::new()));
ensure_object(servers).insert(id.to_string(), server_config);
}
let mcp_value = full_config
.get("mcp")
.cloned()
.unwrap_or_else(|| json!({ "servers": {} }));
write_root_section("mcp", &mcp_value)
}
pub fn remove_mcp_server(id: &str) -> Result<OpenClawWriteOutcome, AppError> {
let mut config = read_openclaw_config()?;
let mut removed = false;
if let Some(servers) = config
.get_mut("mcp")
.and_then(|mcp| mcp.get_mut("servers"))
.and_then(Value::as_object_mut)
{
removed = servers.remove(id).is_some();
}
if !removed {
return Ok(OpenClawWriteOutcome::default());
}
let mcp_value = config
.get("mcp")
.cloned()
.unwrap_or_else(|| json!({ "servers": {} }));
write_root_section("mcp", &mcp_value)
}
pub fn set_provider(id: &str, provider_config: Value) -> Result<OpenClawWriteOutcome, AppError> {
let mut full_config = read_openclaw_config()?;
{
let root = ensure_object(&mut full_config);
let models = root.entry("models".to_string()).or_insert_with(|| {
json!({
"mode": "merge",
"providers": {}
})
});
let providers = ensure_object(models)
.entry("providers".to_string())
.or_insert_with(|| Value::Object(Map::new()));
ensure_object(providers).insert(id.to_string(), provider_config);
}
let models_value = full_config.get("models").cloned().unwrap_or_else(|| {
json!({
"mode": "merge",
"providers": {}
})
});
write_root_section("models", &models_value)
}
pub fn remove_provider(id: &str) -> Result<OpenClawWriteOutcome, AppError> {
let mut config = read_openclaw_config()?;
let mut removed = false;
if let Some(providers) = config
.get_mut("models")
.and_then(|models| models.get_mut("providers"))
.and_then(Value::as_object_mut)
{
removed = providers.remove(id).is_some();
}
let pruned_catalog = remove_provider_model_catalog_entries(&mut config, id);
if !removed && !pruned_catalog {
return Ok(OpenClawWriteOutcome::default());
}
match (removed, pruned_catalog) {
(true, true) => {
let models_value = config.get("models").cloned().unwrap_or_else(|| {
json!({
"mode": "merge",
"providers": {}
})
});
let agents_value = config
.get("agents")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
write_root_sections(&[("models", models_value), ("agents", agents_value)])
}
(true, false) => {
let models_value = config.get("models").cloned().unwrap_or_else(|| {
json!({
"mode": "merge",
"providers": {}
})
});
write_root_section("models", &models_value)
}
(false, true) => {
let agents_value = config
.get("agents")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
write_root_section("agents", &agents_value)
}
(false, false) => Ok(OpenClawWriteOutcome::default()),
}
}
pub fn get_default_model() -> Result<Option<OpenClawDefaultModel>, AppError> {
let config = read_openclaw_config()?;
default_model_from_config(&config)
}
pub fn set_default_model(model: &OpenClawDefaultModel) -> Result<OpenClawWriteOutcome, AppError> {
let mut config = read_openclaw_config()?;
let model_value =
serde_json::to_value(model).map_err(|source| AppError::JsonSerialize { source })?;
{
let root = ensure_object(&mut config);
let agents = root
.entry("agents".to_string())
.or_insert_with(|| Value::Object(Map::new()));
let defaults = ensure_object(agents)
.entry("defaults".to_string())
.or_insert_with(|| Value::Object(Map::new()));
ensure_object(defaults).insert("model".to_string(), model_value);
}
let agents_value = config
.get("agents")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
write_root_section("agents", &agents_value)
}
pub fn get_typed_providers() -> Result<IndexMap<String, OpenClawProviderConfig>, AppError> {
let providers = get_providers()?;
let mut result = IndexMap::new();
for (id, value) in providers {
match serde_json::from_value::<OpenClawProviderConfig>(value.clone()) {
Ok(config) => {
result.insert(id, config);
}
Err(err) => {
log::warn!("Failed to parse OpenClaw provider '{id}': {err}");
}
}
}
Ok(result)
}
pub fn set_typed_provider(
id: &str,
config: &OpenClawProviderConfig,
) -> Result<OpenClawWriteOutcome, AppError> {
let value =
serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source })?;
set_provider(id, value)
}
pub fn get_model_catalog() -> Result<Option<HashMap<String, OpenClawModelCatalogEntry>>, AppError> {
let config = read_openclaw_config()?;
let Some(models_value) = config
.get("agents")
.and_then(|agents| agents.get("defaults"))
.and_then(|defaults| defaults.get("models"))
else {
return Ok(None);
};
let catalog = serde_json::from_value(models_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.models: {e}")))?;
Ok(Some(catalog))
}
pub fn set_model_catalog(
catalog: &HashMap<String, OpenClawModelCatalogEntry>,
) -> Result<OpenClawWriteOutcome, AppError> {
let mut config = read_openclaw_config()?;
let catalog_value =
serde_json::to_value(catalog).map_err(|source| AppError::JsonSerialize { source })?;
{
let root = ensure_object(&mut config);
let agents = root
.entry("agents".to_string())
.or_insert_with(|| Value::Object(Map::new()));
let defaults = ensure_object(agents)
.entry("defaults".to_string())
.or_insert_with(|| Value::Object(Map::new()));
ensure_object(defaults).insert("models".to_string(), catalog_value);
}
let agents_value = config
.get("agents")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
write_root_section("agents", &agents_value)
}
pub fn get_agents_defaults() -> Result<Option<OpenClawAgentsDefaults>, AppError> {
let config = read_openclaw_config()?;
let Some(defaults_value) = config
.get("agents")
.and_then(|agents| agents.get("defaults"))
else {
return Ok(None);
};
let defaults = serde_json::from_value(defaults_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults: {e}")))?;
Ok(Some(defaults))
}
pub fn set_agents_defaults(
defaults: &OpenClawAgentsDefaults,
) -> Result<OpenClawWriteOutcome, AppError> {
let mut config = read_openclaw_config()?;
let mut defaults_value =
serde_json::to_value(defaults).map_err(|source| AppError::JsonSerialize { source })?;
remove_legacy_timeout(&mut defaults_value);
{
let root = ensure_object(&mut config);
let agents = root
.entry("agents".to_string())
.or_insert_with(|| Value::Object(Map::new()));
ensure_object(agents).insert("defaults".to_string(), defaults_value);
}
let agents_value = config
.get("agents")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
write_root_section("agents", &agents_value)
}
pub fn get_env_config() -> Result<OpenClawEnvConfig, AppError> {
let config = read_openclaw_config()?;
let Some(env_value) = config.get("env") else {
return Ok(OpenClawEnvConfig {
vars: HashMap::new(),
});
};
serde_json::from_value(env_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse env config: {e}")))
}
pub fn set_env_config(env: &OpenClawEnvConfig) -> Result<OpenClawWriteOutcome, AppError> {
let value = serde_json::to_value(env).map_err(|source| AppError::JsonSerialize { source })?;
write_root_section("env", &value)
}
pub fn get_tools_config() -> Result<OpenClawToolsConfig, AppError> {
let config = read_openclaw_config()?;
let Some(tools_value) = config.get("tools") else {
return Ok(OpenClawToolsConfig {
profile: None,
allow: Vec::new(),
deny: Vec::new(),
extra: HashMap::new(),
});
};
serde_json::from_value(tools_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse tools config: {e}")))
}
pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<OpenClawWriteOutcome, AppError> {
let value = serde_json::to_value(tools).map_err(|source| AppError::JsonSerialize { source })?;
write_root_section("tools", &value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::get_app_config_dir;
use crate::settings::{get_settings, update_settings, AppSettings};
use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
use serde_json::json;
use serial_test::serial;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use tempfile::tempdir;
struct SettingsGuard {
previous: AppSettings,
}
impl SettingsGuard {
fn with_openclaw_dir(path: &std::path::Path) -> Self {
let previous = get_settings();
let mut settings = AppSettings::default();
settings.openclaw_config_dir = Some(path.display().to_string());
update_settings(settings).expect("set openclaw override dir");
Self { previous }
}
}
impl Drop for SettingsGuard {
fn drop(&mut self) {
update_settings(self.previous.clone()).expect("restore previous settings");
}
}
struct HomeGuard {
old_home: Option<std::ffi::OsString>,
old_userprofile: Option<std::ffi::OsString>,
old_config_dir: Option<std::ffi::OsString>,
old_test_home: Option<std::ffi::OsString>,
}
impl HomeGuard {
fn set(home: &Path) -> Self {
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
unsafe {
std::env::set_var("HOME", home);
}
unsafe {
std::env::set_var("USERPROFILE", home);
}
unsafe {
std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
}
unsafe {
std::env::set_var("CC_SWITCH_TEST_HOME", home);
}
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
old_home,
old_userprofile,
old_config_dir,
old_test_home,
}
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.old_home.take() {
Some(value) => unsafe { std::env::set_var("HOME", value) },
None => unsafe { std::env::remove_var("HOME") },
}
match self.old_userprofile.take() {
Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
None => unsafe { std::env::remove_var("USERPROFILE") },
}
match self.old_config_dir.take() {
Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
}
match self.old_test_home.take() {
Some(value) => unsafe { std::env::set_var("CC_SWITCH_TEST_HOME", value) },
None => unsafe { std::env::remove_var("CC_SWITCH_TEST_HOME") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
}
}
fn with_test_paths(source: &str, test: impl FnOnce(&Path)) {
let _guard = lock_test_home_and_settings();
let temp = tempdir().expect("create tempdir");
let openclaw_dir = temp.path().join(".openclaw");
fs::create_dir_all(&openclaw_dir).expect("create openclaw dir");
let config_path = openclaw_dir.join("openclaw.json");
fs::write(&config_path, source).expect("seed openclaw config");
let _home = HomeGuard::set(temp.path());
let _settings = SettingsGuard::with_openclaw_dir(&openclaw_dir);
test(&config_path);
}
#[test]
#[serial]
fn missing_config_returns_default_models_object() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
let config = read_openclaw_config().expect("read default config");
assert_eq!(config["models"]["mode"], json!("merge"));
assert_eq!(config["models"]["providers"], json!({}));
}
#[test]
#[serial]
fn read_openclaw_config_accepts_json5_syntax() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
let source = r#"{
// json5 comments should be accepted
models: {
mode: 'merge',
providers: {
demo: {
baseUrl: 'https://example.test/v1',
apiKey: 'sk-demo',
},
},
},
}
"#;
fs::write(get_openclaw_config_path(), source).expect("write json5 config");
let config = read_openclaw_config().expect("parse json5 config");
assert_eq!(
config["models"]["providers"]["demo"]["baseUrl"],
json!("https://example.test/v1")
);
assert_eq!(
config["models"]["providers"]["demo"]["apiKey"],
json!("sk-demo")
);
}
#[test]
#[serial]
fn read_openclaw_config_does_not_rewrite_string_contents() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
let source = r#"{
models: {
mode: 'merge',
providers: {
demo: {
note: '{foo:1},}',
prompt: 'keep ,} and {bar:2} literally',
},
},
},
}
"#;
fs::write(get_openclaw_config_path(), source).expect("write json5 config");
let config = read_openclaw_config().expect("parse json5 config without rewriting strings");
assert_eq!(
config["models"]["providers"]["demo"]["note"],
json!("{foo:1},}")
);
assert_eq!(
config["models"]["providers"]["demo"]["prompt"],
json!("keep ,} and {bar:2} literally")
);
}
#[test]
#[serial]
fn set_and_remove_provider_only_touch_target_entry() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
fs::write(
get_openclaw_config_path(),
r#"{
models: {
mode: 'merge',
providers: {
keep: { baseUrl: 'https://keep.test' },
remove: { baseUrl: 'https://remove.test' },
},
},
}
"#,
)
.expect("seed json5 config");
set_provider("added", json!({ "baseUrl": "https://added.test" })).expect("set provider");
remove_provider("remove").expect("remove provider");
let providers = get_providers().expect("read providers");
assert!(providers.contains_key("keep"));
assert!(providers.contains_key("added"));
assert!(!providers.contains_key("remove"));
}
#[test]
#[serial]
fn remove_provider_prunes_matching_agents_default_model_catalog_entries() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
fs::write(
get_openclaw_config_path(),
r#"{
models: {
mode: 'merge',
providers: {
keep: {
models: [{ id: 'primary' }],
},
remove: {
models: [{ id: 'primary' }, { id: 'fallback' }],
},
},
},
agents: {
defaults: {
model: {
primary: 'keep/primary',
},
models: {
'keep/primary': { alias: 'Keep Primary' },
'remove/primary': { alias: 'Remove Primary' },
'remove/fallback': { alias: 'Remove Fallback' },
},
},
},
}
"#,
)
.expect("seed json5 config");
remove_provider("remove").expect("remove provider");
let config = read_openclaw_config().expect("read config after provider removal");
assert!(config["models"]["providers"].get("keep").is_some());
assert!(config["models"]["providers"].get("remove").is_none());
assert_eq!(
config["agents"]["defaults"]["models"]["keep/primary"]["alias"],
json!("Keep Primary")
);
assert!(
config["agents"]["defaults"]["models"]
.get("remove/primary")
.is_none(),
"removed provider primary catalog entry should be pruned"
);
assert!(
config["agents"]["defaults"]["models"]
.get("remove/fallback")
.is_none(),
"removed provider fallback catalog entry should be pruned"
);
}
#[test]
#[serial]
fn remove_provider_prunes_stale_agents_catalog_even_when_provider_is_already_missing() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
fs::write(
get_openclaw_config_path(),
r#"{
models: {
mode: 'merge',
providers: {
keep: {
models: [{ id: 'primary' }],
},
},
},
agents: {
defaults: {
model: {
primary: 'keep/primary',
},
models: {
'keep/primary': { alias: 'Keep Primary' },
'stale/primary': {},
},
},
},
}
"#,
)
.expect("seed json5 config");
remove_provider("stale").expect("remove stale catalog references");
let config = read_openclaw_config().expect("read config after stale cleanup");
assert!(config["models"]["providers"].get("keep").is_some());
assert_eq!(
config["agents"]["defaults"]["models"]["keep/primary"]["alias"],
json!("Keep Primary")
);
assert!(
config["agents"]["defaults"]["models"]
.get("stale/primary")
.is_none(),
"stale provider catalog entry should be pruned even when provider is already absent"
);
}
#[test]
#[serial]
fn remove_missing_provider_is_noop_and_does_not_create_file() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
let path = get_openclaw_config_path();
assert!(!path.exists(), "precondition: config file should be absent");
remove_provider("missing").expect("removing a missing provider should be a no-op");
assert!(
!path.exists(),
"no-op remove should not create a new openclaw.json"
);
}
#[test]
#[serial]
fn remove_last_provider_keeps_empty_providers_map() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
fs::write(
get_openclaw_config_path(),
r#"{
models: {
mode: 'merge',
providers: {
only: { baseUrl: 'https://only.test' },
},
},
}
"#,
)
.expect("seed json5 config with single provider");
remove_provider("only").expect("remove last provider should succeed");
let config = read_openclaw_config().expect("read config after removing last provider");
assert_eq!(config["models"]["mode"], json!("merge"));
assert_eq!(config["models"]["providers"], json!({}));
}
#[test]
#[serial]
fn remove_last_provider_rewrites_models_section_like_upstream() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
fs::write(
get_openclaw_config_path(),
r#"{
// preserve top-level comment
models: {
mode: 'merge',
// preserve providers comment
providers: {
only: { baseUrl: 'https://only.test' },
},
},
tools: {
profile: 'coding',
},
}
"#,
)
.expect("seed json5 config with comments and single quotes");
remove_provider("only").expect("remove last provider should succeed");
let written = fs::read_to_string(get_openclaw_config_path())
.expect("read config after removing last provider");
assert!(
written.contains("// preserve top-level comment"),
"top-level comment should survive targeted remove: {written}"
);
assert!(
written.contains("\"mode\": \"merge\""),
"rewritten models section should use strict JSON key/string quoting: {written}"
);
assert!(
!written.contains("// preserve providers comment"),
"rewriting the models subtree should drop providers-level comments like upstream: {written}"
);
assert!(
written.contains("\"providers\": {}"),
"providers map should become an empty object after rewrite: {written}"
);
assert!(
written.contains("profile: 'coding'"),
"unrelated sections should preserve existing source text: {written}"
);
}
#[test]
fn serialize_section_value_preserves_empty_objects() {
let models_value = json!({
"mode": "merge",
"providers": {}
});
let empty_tools_value = json!({});
let env_value = json!({
"vars": {}
});
assert_eq!(
serialize_section_value("models", &models_value).expect("serialize models"),
"{\n \"mode\": \"merge\",\n \"providers\": {}\n}"
);
assert_eq!(
serialize_section_value("tools", &empty_tools_value).expect("serialize tools"),
"{}"
);
assert_eq!(
serialize_section_value("env", &env_value).expect("serialize env"),
"{\n \"vars\": {}\n}"
);
}
#[test]
fn serialize_section_value_uses_strict_json_style_for_regular_sections() {
let env_value = json!({
"vars": {
"TOKEN": "value"
}
});
let tools_value = json!({
"profile": "coding",
"allow": ["Read"]
});
let actual_env = serialize_section_value("env", &env_value)
.expect("serialize non-fallback shape should succeed");
let actual_tools = serialize_section_value("tools", &tools_value)
.expect("serialize non-empty tools shape should succeed");
assert_eq!(
actual_env,
"{\n \"vars\": {\n \"TOKEN\": \"value\"\n }\n}"
);
assert_eq!(
actual_tools,
"{\n \"allow\": [\n \"Read\"\n ],\n \"profile\": \"coding\"\n}"
);
serde_json::from_str::<Value>(&actual_env).expect("env output should remain valid JSON");
serde_json::from_str::<Value>(&actual_tools)
.expect("tools output should remain valid JSON");
}
#[test]
#[serial]
fn default_model_round_trip_preserves_existing_providers() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
fs::write(
get_openclaw_config_path(),
r#"{
models: {
mode: 'merge',
providers: {
demo: {
baseUrl: 'https://demo.test/v1',
apiKey: 'sk-demo',
models: [
{ id: 'gpt-4.1' },
{ id: 'gpt-4.1-mini' },
],
},
},
},
}
"#,
)
.expect("seed openclaw config");
let model = OpenClawDefaultModel {
primary: "demo/gpt-4.1".to_string(),
fallbacks: vec!["demo/gpt-4.1-mini".to_string()],
extra: std::collections::HashMap::from([(
"reasoningEffort".to_string(),
json!("high"),
)]),
};
set_default_model(&model).expect("write default model");
assert_eq!(
get_default_model().expect("read default model"),
Some(model.clone())
);
let providers = get_providers().expect("read providers after default-model write");
assert!(
providers.contains_key("demo"),
"writing default model should not drop provider entries"
);
}
#[test]
#[serial]
fn default_model_reader_accepts_string_shape() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
fs::write(
get_openclaw_config_path(),
r#"{
agents: {
defaults: {
model: 'demo/gpt-4.1',
},
},
}
"#,
)
.expect("seed openclaw config");
let model = get_default_model()
.expect("read string-shaped default model")
.expect("default model should exist");
assert_eq!(model.primary, "demo/gpt-4.1");
assert!(model.fallbacks.is_empty());
let defaults = get_agents_defaults()
.expect("read agents defaults")
.expect("agents defaults should exist");
assert_eq!(defaults.model, Some(model));
}
#[test]
#[serial]
fn default_model_reader_accepts_null_and_string_fallbacks() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
fs::write(
get_openclaw_config_path(),
r#"{
agents: {
defaults: {
model: {
primary: 'demo/gpt-4.1',
fallbacks: 'demo/gpt-4.1-mini',
},
},
},
}
"#,
)
.expect("seed openclaw config");
let model = get_default_model()
.expect("read string fallback default model")
.expect("default model should exist");
assert_eq!(model.primary, "demo/gpt-4.1");
assert_eq!(model.fallbacks, vec!["demo/gpt-4.1-mini".to_string()]);
fs::write(
get_openclaw_config_path(),
r#"{
agents: {
defaults: {
model: {
primary: 'demo/gpt-4.1',
fallbacks: null,
},
},
},
}
"#,
)
.expect("seed openclaw config");
let model = get_default_model()
.expect("read null fallback default model")
.expect("default model should exist");
assert_eq!(model.primary, "demo/gpt-4.1");
assert!(model.fallbacks.is_empty());
}
#[test]
#[serial]
fn typed_provider_round_trip_preserves_known_and_unknown_fields() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
let mut model_extra = std::collections::HashMap::new();
model_extra.insert("providerHint".to_string(), json!("reasoning"));
let mut provider_extra = std::collections::HashMap::new();
provider_extra.insert("region".to_string(), json!("us-east-1"));
let config = OpenClawProviderConfig {
base_url: Some("https://example.test/v1".to_string()),
api_key: Some("sk-test".to_string()),
api: Some("openai-responses".to_string()),
models: vec![crate::provider::OpenClawModelEntry {
id: "gpt-4.1".to_string(),
name: Some("GPT-4.1".to_string()),
alias: Some("fast".to_string()),
cost: Some(crate::provider::OpenClawModelCost {
input: 0.1,
output: 0.2,
extra: std::collections::HashMap::new(),
}),
context_window: Some(200000),
extra: model_extra,
}],
headers: std::collections::HashMap::from([("x-org".to_string(), "demo".to_string())]),
extra: provider_extra,
};
set_typed_provider("demo", &config).expect("write typed provider");
let providers = get_typed_providers().expect("read typed providers");
let provider = providers.get("demo").expect("typed provider should exist");
assert_eq!(
provider.base_url.as_deref(),
Some("https://example.test/v1")
);
assert_eq!(provider.api_key.as_deref(), Some("sk-test"));
assert_eq!(provider.api.as_deref(), Some("openai-responses"));
assert_eq!(provider.models.len(), 1);
assert_eq!(provider.models[0].id, "gpt-4.1");
assert_eq!(provider.models[0].context_window, Some(200000));
assert_eq!(
provider.models[0].extra.get("providerHint"),
Some(&json!("reasoning"))
);
assert_eq!(provider.extra.get("region"), Some(&json!("us-east-1")));
}
#[test]
#[serial]
fn get_providers_reads_multiple_json5_entries() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _settings = SettingsGuard::with_openclaw_dir(dir.path());
let source = r#"{
models: {
mode: 'merge',
providers: {
openai: {
name: 'OpenAI Compatible',
apiKey: 'sk-openai',
},
anthropic: {
name: 'Anthropic',
api_key: 'sk-anthropic',
},
},
},
}
"#;
fs::write(get_openclaw_config_path(), source).expect("write json5 config");
let providers = get_providers().expect("read providers from json5 config");
assert_eq!(providers.len(), 2);
assert_eq!(providers["openai"]["apiKey"], json!("sk-openai"));
assert_eq!(providers["anthropic"]["api_key"], json!("sk-anthropic"));
}
#[test]
#[serial]
fn scan_openclaw_config_health_returns_parse_warning_for_invalid_json5() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let _home = HomeGuard::set(dir.path());
let _settings = SettingsGuard::with_openclaw_dir(&dir.path().join(".openclaw"));
fs::create_dir_all(get_openclaw_dir()).expect("create openclaw dir");
fs::write(get_openclaw_config_path(), "{ broken: [ }").expect("write invalid json5");
let warnings = scan_openclaw_config_health().expect("scan health should not fail");
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].code, "config_parse_failed");
assert!(warnings[0].path.is_some());
}
#[test]
#[serial]
fn default_model_write_preserves_top_level_comments() {
let source = r#"{
// top-level comment
models: {
mode: 'merge',
providers: {
provider: {
models: [{ id: 'model' }],
},
},
},
}
"#;
with_test_paths(source, |_| {
let outcome = set_default_model(&OpenClawDefaultModel {
primary: "provider/model".to_string(),
fallbacks: Vec::new(),
extra: HashMap::new(),
})
.expect("write default model");
assert!(outcome.backup_path.is_some());
let written =
fs::read_to_string(get_openclaw_config_path()).expect("read written config");
assert!(written.contains("// top-level comment"));
assert!(written.contains("\"agents\": {"));
assert!(!written.contains("agents: {"));
assert!(written.contains("provider/model"));
});
}
#[test]
#[serial]
fn default_model_noop_write_skips_backup() {
let source = r#"{
models: {
mode: 'merge',
providers: {
provider: {
models: [
{ id: 'model' },
{ id: 'fallback' },
],
},
},
},
}
"#;
with_test_paths(source, |_| {
let model = OpenClawDefaultModel {
primary: "provider/model".to_string(),
fallbacks: vec!["provider/fallback".to_string()],
extra: HashMap::new(),
};
let first_outcome = set_default_model(&model).expect("first default-model write");
assert!(first_outcome.backup_path.is_some());
let first_written =
fs::read_to_string(get_openclaw_config_path()).expect("read first written config");
let backup_dir = get_app_config_dir().join("backups").join("openclaw");
let backup_count = fs::read_dir(&backup_dir).expect("read backup dir").count();
assert_eq!(backup_count, 1);
let second_outcome = set_default_model(&model).expect("second default-model write");
assert!(second_outcome.backup_path.is_none());
let second_written =
fs::read_to_string(get_openclaw_config_path()).expect("read second written config");
assert_eq!(second_written, first_written);
assert_eq!(
fs::read_dir(&backup_dir)
.expect("re-read backup dir")
.count(),
backup_count
);
});
}
#[test]
#[serial]
fn default_model_write_handles_empty_model_catalog_entries() {
let source = r#"{
models: {
mode: 'merge',
providers: {
p1: {
models: [
{ id: 'primary' },
{ id: 'fallback' },
],
},
},
},
agents: {
defaults: {
model: {
primary: 'p1/primary',
},
models: {
'p1/primary': {},
'p1/fallback': {},
},
},
},
}
"#;
with_test_paths(source, |_| {
set_default_model(&OpenClawDefaultModel {
primary: "p1/fallback".to_string(),
fallbacks: vec!["p1/primary".to_string()],
extra: HashMap::new(),
})
.expect("write default model with empty catalog entries");
let config = read_openclaw_config().expect("read rewritten config");
assert_eq!(
config["agents"]["defaults"]["model"]["primary"],
json!("p1/fallback")
);
assert_eq!(
config["agents"]["defaults"]["models"]["p1/primary"],
json!({})
);
assert_eq!(
config["agents"]["defaults"]["models"]["p1/fallback"],
json!({})
);
});
}
#[test]
#[serial]
fn backup_cleanup_uses_settings_retain_count() {
let _guard = lock_test_home_and_settings();
let dir = tempdir().expect("create tempdir");
let openclaw_dir = dir.path().join(".openclaw");
fs::create_dir_all(&openclaw_dir).expect("create openclaw dir");
let _home = HomeGuard::set(dir.path());
let previous = get_settings();
let mut settings = AppSettings::default();
settings.openclaw_config_dir = Some(openclaw_dir.display().to_string());
settings.backup_retain_count = Some(2);
update_settings(settings).expect("set settings with backup retain count");
fs::write(
get_openclaw_config_path(),
r#"{
models: {
mode: 'merge',
providers: {
provider: {
models: [
{ id: 'model-0' },
{ id: 'model-1' },
{ id: 'model-2' },
{ id: 'model-3' },
],
},
},
},
}
"#,
)
.expect("seed openclaw config");
for i in 0..4 {
set_default_model(&OpenClawDefaultModel {
primary: format!("provider/model-{i}"),
fallbacks: Vec::new(),
extra: HashMap::new(),
})
.expect("write default model for backup pruning");
}
let backup_dir = get_app_config_dir().join("backups").join("openclaw");
let backup_count = fs::read_dir(&backup_dir).expect("read backup dir").count();
assert_eq!(backup_count, 2, "backup pruning should follow settings");
update_settings(previous).expect("restore previous settings");
}
#[test]
#[serial]
fn save_detects_external_conflict() {
let source = r#"{
models: {
mode: 'merge',
providers: {},
},
}
"#;
with_test_paths(source, |config_path| {
let mut document = OpenClawConfigDocument::load().expect("load document");
document
.set_root_section("env", &json!({ "TOKEN": "value" }))
.expect("set env root section");
fs::write(config_path, "{ changedExternally: true }\n")
.expect("overwrite config externally");
let err = document
.save()
.expect_err("save should detect external change");
assert!(err.to_string().contains("OpenClaw config changed on disk"));
});
}
#[test]
#[serial]
fn model_catalog_round_trip_preserves_existing_default_model() {
let source = r#"{
models: {
mode: 'merge',
providers: {
demo: {
models: [
{ id: 'model-primary' },
{ id: 'model-fallback' },
],
},
},
},
agents: {
defaults: {
model: {
primary: 'demo/model-primary',
},
},
},
}
"#;
with_test_paths(source, |_| {
let catalog = HashMap::from([
(
"demo/model-primary".to_string(),
OpenClawModelCatalogEntry {
alias: Some("Primary".to_string()),
extra: HashMap::from([("tier".to_string(), json!("gold"))]),
},
),
(
"demo/model-fallback".to_string(),
OpenClawModelCatalogEntry {
alias: Some("Fallback".to_string()),
extra: HashMap::new(),
},
),
]);
set_model_catalog(&catalog).expect("write model catalog");
assert_eq!(
get_model_catalog().expect("read model catalog"),
Some(catalog)
);
assert_eq!(
get_default_model().expect("read preserved default model"),
Some(OpenClawDefaultModel {
primary: "demo/model-primary".to_string(),
fallbacks: Vec::new(),
extra: HashMap::new(),
})
);
});
}
#[test]
#[serial]
fn agents_defaults_round_trip_strips_legacy_timeout_and_preserves_extra_fields() {
let source = r#"{
models: {
mode: 'merge',
providers: {
demo: {
models: [
{ id: 'model-primary' },
{ id: 'model-fallback' },
],
},
},
},
}
"#;
with_test_paths(source, |_| {
let defaults = OpenClawAgentsDefaults {
model: Some(OpenClawDefaultModel {
primary: "demo/model-primary".to_string(),
fallbacks: vec!["demo/model-fallback".to_string()],
extra: HashMap::new(),
}),
models: Some(HashMap::from([(
"demo/model-primary".to_string(),
OpenClawModelCatalogEntry {
alias: Some("Primary".to_string()),
extra: HashMap::new(),
},
)])),
extra: HashMap::from([
("timeout".to_string(), json!(30)),
("timeoutSeconds".to_string(), json!(45)),
("reasoningEffort".to_string(), json!("high")),
]),
};
set_agents_defaults(&defaults).expect("write full agents.defaults");
let round_trip = get_agents_defaults()
.expect("read full agents.defaults")
.expect("agents.defaults should exist");
assert_eq!(round_trip.model, defaults.model);
assert_eq!(round_trip.models, defaults.models);
assert_eq!(round_trip.extra.get("timeoutSeconds"), Some(&json!(45)));
assert_eq!(
round_trip.extra.get("reasoningEffort"),
Some(&json!("high"))
);
assert!(
!round_trip.extra.contains_key("timeout"),
"legacy timeout should be stripped on write"
);
});
}
#[test]
#[serial]
fn env_and_tools_section_helpers_round_trip() {
let source = r#"{
// top-level comment
models: {
mode: 'merge',
providers: {},
},
}
"#;
with_test_paths(source, |_| {
let env = OpenClawEnvConfig {
vars: HashMap::from([
("vars".to_string(), json!({ "TOKEN": "value" })),
("shellEnv".to_string(), json!({ "PATH": "/usr/bin" })),
]),
};
let tools = OpenClawToolsConfig {
profile: Some("coding".to_string()),
allow: vec!["Read".to_string()],
deny: vec!["Bash(rm:*)".to_string()],
extra: HashMap::from([("telemetry".to_string(), json!(true))]),
};
set_env_config(&env).expect("write env section");
set_tools_config(&tools).expect("write tools section");
assert_eq!(get_env_config().expect("read env section"), env);
assert_eq!(get_tools_config().expect("read tools section"), tools);
});
}
#[test]
#[serial]
fn scan_openclaw_health_detects_invalid_tools_and_env_values() {
let source = r#"{
models: {
mode: 'merge',
providers: {},
},
tools: {
profile: 'default',
},
agents: {
defaults: {
timeout: 30,
},
},
env: {
vars: '[object Object]',
shellEnv: 'oops',
},
}
"#;
with_test_paths(source, |_| {
let warnings = scan_openclaw_config_health().expect("scan config health");
let codes = warnings
.into_iter()
.map(|warning| warning.code)
.collect::<Vec<_>>();
assert!(codes.contains(&"invalid_tools_profile".to_string()));
assert!(codes.contains(&"legacy_agents_timeout".to_string()));
assert!(codes.contains(&"stringified_env_vars".to_string()));
assert!(codes.contains(&"stringified_env_shell_env".to_string()));
});
}
}