fix(openclaw): 兼容新版配置与 Node 门槛

This commit is contained in:
晴天
2026-06-11 15:24:01 +08:00
parent 5aa09f4bb7
commit 675ad1628b
33 changed files with 717 additions and 130 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -366,7 +366,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.18.2"
version = "0.18.3"
dependencies = [
"base64 0.22.1",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "clawpanel"
version = "0.18.2"
version = "0.18.3"
edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"]

View File

@@ -210,6 +210,13 @@ fn read_package_json_field(path: &std::path::Path, pointer: &str) -> Option<Stri
.filter(|v| !v.is_empty())
}
const OPENCLAW_NODE_REQUIREMENT_VERSION_FLOOR: &str = "2026.6.5";
const OPENCLAW_NODE_REQUIREMENT_FOR_NEWER_RUNTIME: &str = ">=22.19.0";
fn openclaw_version_requires_node_22_19(version: &str) -> bool {
parse_version(&base_version(version)) >= parse_version(OPENCLAW_NODE_REQUIREMENT_VERSION_FLOOR)
}
fn find_openclaw_package_json(cli_path: &std::path::Path) -> Option<PathBuf> {
let dir = cli_path.parent()?;
let cli_source = crate::utils::classify_cli_source(&cli_path.to_string_lossy());
@@ -246,8 +253,66 @@ fn find_openclaw_package_json(cli_path: &std::path::Path) -> Option<PathBuf> {
pub(crate) fn openclaw_node_requirement() -> Option<String> {
let cli_path = crate::utils::resolve_openclaw_cli_path()?;
let pkg_json = find_openclaw_package_json(std::path::Path::new(&cli_path))?;
read_package_json_field(&pkg_json, "/engines/node")
let cli_path_ref = std::path::Path::new(&cli_path);
let pkg_json = find_openclaw_package_json(cli_path_ref);
if let Some(pkg_json) = pkg_json.as_ref() {
if let Some(requirement) = read_package_json_field(pkg_json, "/engines/node")
.filter(|requirement| !requirement.trim().is_empty())
{
return Some(requirement);
}
}
let installed_version = pkg_json
.as_ref()
.and_then(|pkg| read_package_json_field(pkg, "/version"))
.or_else(|| read_version_from_installation(cli_path_ref));
installed_version
.filter(|version| openclaw_version_requires_node_22_19(version))
.map(|_| OPENCLAW_NODE_REQUIREMENT_FOR_NEWER_RUNTIME.to_string())
}
fn standalone_bundled_node_bin(cli_path: &str) -> Option<PathBuf> {
let dir = std::path::Path::new(cli_path).parent()?;
#[cfg(target_os = "windows")]
let node_bin = dir.join("node.exe");
#[cfg(not(target_os = "windows"))]
let node_bin = dir.join("node");
node_bin.is_file().then_some(node_bin)
}
fn node_version_from_bin(node_bin: &std::path::Path) -> Option<String> {
let mut cmd = Command::new(node_bin);
cmd.arg("--version");
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let output = cmd.output().ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn populate_node_detection_result(
result: &mut serde_json::Map<String, Value>,
version: String,
path: String,
detected_from: String,
) {
let required_version = openclaw_node_requirement();
let compatible = required_version
.as_deref()
.map(|req| node_version_satisfies_requirement(&version, req))
.unwrap_or(true);
result.insert("installed".into(), Value::Bool(true));
result.insert("version".into(), Value::String(version));
result.insert("path".into(), Value::String(path));
result.insert("detectedFrom".into(), Value::String(detected_from));
result.insert("compatible".into(), Value::Bool(compatible));
result.insert(
"requiredVersion".into(),
required_version.map(Value::String).unwrap_or(Value::Null),
);
}
pub(crate) fn ensure_node_runtime_compatible() -> Result<(), String> {
@@ -1027,7 +1092,7 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> {
// 即使这些字段不在前端传入的配置对象中
let existing_config = fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str::<Value>(&c).ok());
.and_then(|c| parse_json_relaxed(&c));
// 备份
let bak = super::openclaw_dir().join("openclaw.json.bak");
@@ -1060,8 +1125,8 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> {
}
const CALIBRATION_RESET_INHERIT_KEYS: &[&str] = &[
"agents", "auth", "bindings", "browser", "channels", "commands", "env", "hooks", "models",
"plugins", "session", "skills", "wizard",
"agents", "auth", "bindings", "browser", "channels", "commands", "env", "hooks", "memory",
"models", "plugins", "security", "session", "skills", "wizard",
];
fn calibration_required_origins() -> Vec<String> {
@@ -1558,7 +1623,8 @@ pub fn calibrate_openclaw_config(mode: String) -> Result<Value, String> {
///
/// Issue #127: 修复配置合并时丢失 browser.* 等合法字段的问题
///
/// 策略:对所有顶级 Object 类型字段做浅合并(新值覆盖旧值,旧值中新配置没有的字段保留)。
/// 策略:对 Object 类型字段递归合并(新值覆盖旧值,旧值中新配置没有的字段保留)。
/// 数组与标量显式替换避免把模型列表、Agent 列表等顺序集合错误拼接。
/// 这样用户通过 CLI / 手动编辑添加的自定义子字段不会被前端的部分配置所覆盖掉。
///
/// 清理的字段:
@@ -1575,14 +1641,16 @@ fn merge_configs_preserving_fields(existing: &Value, new: &Value) -> Value {
if let (Value::Object(existing_sub), Value::Object(new_sub)) =
(existing_value, new_value)
{
// 两边都是对象:合并(新值覆盖,旧值保留未覆盖的 key
let mut sub_merged = existing_sub.clone();
for (sub_key, sub_value) in new_sub {
sub_merged.insert(sub_key.clone(), sub_value.clone());
}
merged.insert(key.clone(), Value::Object(sub_merged));
// 两边都是对象:递归合并(新值覆盖,旧值保留未覆盖的 key
merged.insert(
key.clone(),
merge_configs_preserving_fields(
&Value::Object(existing_sub.clone()),
&Value::Object(new_sub.clone()),
),
);
} else {
// 类型不同或不是对象,直接使用新值
// 类型不同、数组或标量,直接使用新值
merged.insert(key.clone(), new_value.clone());
}
} else {
@@ -4771,6 +4839,24 @@ pub fn check_node() -> Result<Value, String> {
let mut result = serde_json::Map::new();
let enhanced = super::enhanced_path();
// standalone 安装会在 openclaw 启动脚本中优先使用同目录 Node.js。
// 这里按实际运行时检测,避免被 PATH 中较旧的系统 Node.js 误判拦截。
if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() {
if crate::utils::classify_cli_source(&cli_path) == "standalone" {
if let Some(bundled) = standalone_bundled_node_bin(&cli_path) {
if let Some(ver) = node_version_from_bin(&bundled) {
populate_node_detection_result(
&mut result,
ver,
bundled.to_string_lossy().to_string(),
"standalone-bundled".into(),
);
return Ok(Value::Object(result));
}
}
}
}
// 尝试通过 which/where 命令找到 node 的实际路径
let node_path = find_node_path(&enhanced);
@@ -4783,20 +4869,7 @@ pub fn check_node() -> Result<Value, String> {
Ok(o) if o.status.success() => {
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
let detected_from = detect_node_source(&path);
let required_version = openclaw_node_requirement();
let compatible = required_version
.as_deref()
.map(|req| node_version_satisfies_requirement(&ver, req))
.unwrap_or(true);
result.insert("installed".into(), Value::Bool(true));
result.insert("version".into(), Value::String(ver));
result.insert("path".into(), Value::String(path));
result.insert("detectedFrom".into(), Value::String(detected_from));
result.insert("compatible".into(), Value::Bool(compatible));
result.insert(
"requiredVersion".into(),
required_version.map(Value::String).unwrap_or(Value::Null),
);
populate_node_detection_result(&mut result, ver, path, detected_from);
}
_ => {
result.insert("installed".into(), Value::Bool(false));
@@ -7458,8 +7531,8 @@ pub async fn auto_install_node(app: tauri::AppHandle) -> Result<String, String>
.wait()
.map_err(|e| format!("等待 winget 安装 Node.js 失败: {e}"))?;
if !install_status.success() {
let requirement =
openclaw_node_requirement().unwrap_or_else(|| "22.19.0 或更高版本".to_string());
let requirement = openclaw_node_requirement()
.unwrap_or_else(|| "当前 OpenClaw 要求的版本".to_string());
return Err(format!(
"winget 安装/升级 Node.js 失败,请手动安装满足 {requirement} 的 Node.jshttps://nodejs.org/"
));
@@ -7527,15 +7600,16 @@ pub fn invalidate_path_cache() -> Result<(), String> {
#[cfg(test)]
mod write_openclaw_config_merge_tests {
use super::apply_reset_inheritance;
use super::merge_configs_preserving_fields;
use super::node_version_satisfies_requirement;
use super::openclaw_version_requires_node_22_19;
#[cfg(target_os = "windows")]
use super::resolve_openclaw_cli_input_path;
use super::standalone_bundled_node_bin;
use serde_json::json;
#[cfg(target_os = "windows")]
use std::path::PathBuf;
#[cfg(target_os = "windows")]
fn unique_temp_dir(name: &str) -> PathBuf {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -7580,6 +7654,61 @@ mod write_openclaw_config_merge_tests {
assert_eq!(prov["a"]["baseUrl"], json!("http://example"));
}
#[test]
fn calibration_reset_inherits_memory_and_security_extensions() {
let baseline = json!({});
let seed = json!({
"memory": {
"qmd": { "rerank": false },
},
"security": {
"installPolicy": {
"enabled": true,
"targets": ["skill", "plugin"]
}
}
});
let (next, inherited) = apply_reset_inheritance(baseline, &seed);
assert!(inherited.contains(&"memory".to_string()));
assert!(inherited.contains(&"security".to_string()));
assert_eq!(next["memory"]["qmd"]["rerank"], json!(false));
assert_eq!(
next["security"]["installPolicy"]["targets"][1],
json!("plugin")
);
}
#[test]
fn partial_gateway_patch_preserves_auth_token() {
let existing = json!({
"gateway": {
"auth": { "token": "secret-new" },
"controlUi": { "allowedOrigins": ["http://localhost:3000"] }
}
});
let patch = json!({
"gateway": {
"controlUi": {
"allowedOrigins": ["http://localhost:3000", "tauri://localhost"]
}
}
});
let merged = merge_configs_preserving_fields(&existing, &patch);
assert_eq!(
merged.pointer("/gateway/auth/token"),
Some(&json!("secret-new"))
);
let origins = merged
.pointer("/gateway/controlUi/allowedOrigins")
.and_then(|v| v.as_array())
.expect("allowedOrigins");
assert!(origins.iter().any(|v| v == "tauri://localhost"));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_cli_input_rejects_extensionless_openclaw_shim() {
@@ -7624,6 +7753,14 @@ mod write_openclaw_config_merge_tests {
assert!(node_version_satisfies_requirement("v24.0.0", ">=22.19.0"));
}
#[test]
fn openclaw_node_requirement_floor_starts_at_2026_6_5() {
assert!(!openclaw_version_requires_node_22_19("2026.6.4"));
assert!(openclaw_version_requires_node_22_19("2026.6.5"));
assert!(openclaw_version_requires_node_22_19("2026.6.5-zh.1"));
assert!(openclaw_version_requires_node_22_19("2026.7.1"));
}
#[test]
fn node_requirement_supports_common_or_ranges() {
assert!(node_version_satisfies_requirement(
@@ -7639,4 +7776,23 @@ mod write_openclaw_config_merge_tests {
"^22.19.0 || >=24.0.0"
));
}
#[test]
fn standalone_bundled_node_bin_resolves_next_to_cli() {
let dir = unique_temp_dir("standalone-bundled-node");
std::fs::create_dir_all(&dir).unwrap();
let cli_path = dir.join("openclaw.cmd");
std::fs::write(&cli_path, "@echo off\r\n").unwrap();
#[cfg(target_os = "windows")]
let node_name = "node.exe";
#[cfg(not(target_os = "windows"))]
let node_name = "node";
let node_bin = dir.join(node_name);
std::fs::write(&node_bin, "").unwrap();
let resolved = standalone_bundled_node_bin(&cli_path.to_string_lossy());
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(resolved, Some(node_bin));
}
}

View File

@@ -4944,6 +4944,10 @@ fn build_hermes_memory_config_values(config: &serde_yaml::Value) -> Value {
let flush_min_turns = memory
.map(|map| bounded_hermes_i64(yaml_i64_field(map, "flush_min_turns"), 6, 0, 1000))
.unwrap_or(6);
let qmd = memory.and_then(|map| yaml_get_mapping(map, "qmd"));
let qmd_rerank = qmd
.and_then(|map| yaml_bool_field(map, "rerank"))
.unwrap_or(true);
serde_json::json!({
"memoryEnabled": memory_enabled,
@@ -4952,6 +4956,7 @@ fn build_hermes_memory_config_values(config: &serde_yaml::Value) -> Value {
"userCharLimit": user_char_limit,
"nudgeInterval": nudge_interval,
"flushMinTurns": flush_min_turns,
"qmdRerank": qmd_rerank,
})
}
@@ -5005,6 +5010,8 @@ fn merge_hermes_memory_config(config: &mut serde_yaml::Value, form: &Value) -> R
0,
1000,
)?;
let qmd_rerank = form_bool(form, "qmdRerank")
.unwrap_or_else(|| current["qmdRerank"].as_bool().unwrap_or(true));
let root = ensure_yaml_object(config)?;
let memory = yaml_child_object(root, "memory")?;
@@ -5032,6 +5039,8 @@ fn merge_hermes_memory_config(config: &mut serde_yaml::Value, form: &Value) -> R
yaml_key("flush_min_turns"),
serde_yaml::Value::Number(flush_min_turns.into()),
);
let qmd = yaml_child_object(memory, "qmd")?;
qmd.insert(yaml_key("rerank"), serde_yaml::Value::Bool(qmd_rerank));
Ok(())
}
@@ -6816,15 +6825,39 @@ fn build_hermes_security_config_values(config: &serde_yaml::Value) -> Value {
let tirith_fail_open = security
.and_then(|map| yaml_bool_field(map, "tirith_fail_open"))
.unwrap_or(true);
let install_policy_json = security
.and_then(|map| yaml_get(map, "installPolicy"))
.and_then(|value| serde_json::to_value(value).ok())
.filter(|value| value.is_object())
.and_then(|value| serde_json::to_string_pretty(&value).ok())
.unwrap_or_default();
serde_json::json!({
"tirithEnabled": tirith_enabled,
"tirithPath": tirith_path,
"tirithTimeout": tirith_timeout,
"tirithFailOpen": tirith_fail_open,
"installPolicyJson": install_policy_json,
})
}
fn parse_hermes_install_policy_json(
raw: Option<String>,
) -> Result<Option<serde_yaml::Value>, String> {
let text = raw.unwrap_or_default().trim().to_string();
if text.is_empty() {
return Ok(None);
}
let value: Value = serde_json::from_str(&text)
.map_err(|err| format!("security.installPolicy JSON 格式错误: {err}"))?;
if !value.is_object() {
return Err("security.installPolicy 必须是 JSON 对象".to_string());
}
serde_yaml::to_value(value)
.map(Some)
.map_err(|err| format!("security.installPolicy 转换 YAML 失败: {err}"))
}
fn merge_hermes_security_config(
config: &mut serde_yaml::Value,
form: &Value,
@@ -6851,6 +6884,14 @@ fn merge_hermes_security_config(
1,
300,
)?;
let install_policy =
parse_hermes_install_policy_json(if form.get("installPolicyJson").is_some() {
form_string(form, "installPolicyJson")
} else {
current["installPolicyJson"]
.as_str()
.map(ToString::to_string)
})?;
let security = yaml_child_object(root, "security")?;
security.insert(
yaml_key("tirith_enabled"),
@@ -6874,6 +6915,11 @@ fn merge_hermes_security_config(
.unwrap_or_else(|| current["tirithFailOpen"].as_bool().unwrap_or(true)),
),
);
if let Some(install_policy) = install_policy {
security.insert(yaml_key("installPolicy"), install_policy);
} else {
security.remove(yaml_key("installPolicy"));
}
Ok(())
}
@@ -7999,6 +8045,7 @@ fn normalize_hermes_web_backend(
backend.as_str(),
"tavily"
| "firecrawl"
| "parallel-free"
| "parallel"
| "exa"
| "searxng"
@@ -8011,7 +8058,7 @@ fn normalize_hermes_web_backend(
return Ok(backend);
}
if strict {
Err(format!("{key} 必须为空或 tavily、firecrawl、parallel、exa、searxng、brave、brave_free、ddgs、xai、native"))
Err(format!("{key} 必须为空或 tavily、firecrawl、parallel-free、parallel、exa、searxng、brave、brave_free、ddgs、xai、native"))
} else {
Ok(String::new())
}
@@ -19407,7 +19454,7 @@ streaming:
merge_hermes_web_config(
&mut config,
&json!({
"webBackend": "parallel",
"webBackend": "parallel-free",
"webSearchBackend": "exa",
"webExtractBackend": "native",
}),
@@ -19416,7 +19463,7 @@ streaming:
assert_eq!(config["model"]["provider"].as_str(), Some("anthropic"));
assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true));
assert_eq!(config["web"]["backend"].as_str(), Some("parallel"));
assert_eq!(config["web"]["backend"].as_str(), Some("parallel-free"));
assert_eq!(config["web"]["search_backend"].as_str(), Some("exa"));
assert_eq!(config["web"]["extract_backend"].as_str(), Some("native"));
assert_eq!(config["web"]["custom_flag"].as_str(), Some("keep-web"));
@@ -21510,6 +21557,7 @@ mod hermes_memory_config_tests {
assert_eq!(values["userCharLimit"], 1375);
assert_eq!(values["nudgeInterval"], 10);
assert_eq!(values["flushMinTurns"], 6);
assert_eq!(values["qmdRerank"], true);
}
#[test]
@@ -21523,6 +21571,9 @@ memory:
provider: honcho
custom_flag: keep-me
flush_min_turns: 9
qmd:
provider: qmd
rerank: true
streaming:
enabled: true
"#,
@@ -21538,6 +21589,7 @@ streaming:
"userCharLimit": "1500",
"nudgeInterval": "0",
"flushMinTurns": "7",
"qmdRerank": false,
}),
)
.unwrap();
@@ -21553,6 +21605,8 @@ streaming:
assert_eq!(config["memory"]["user_char_limit"].as_i64(), Some(1500));
assert_eq!(config["memory"]["nudge_interval"].as_i64(), Some(0));
assert_eq!(config["memory"]["flush_min_turns"].as_i64(), Some(7));
assert_eq!(config["memory"]["qmd"]["rerank"].as_bool(), Some(false));
assert_eq!(config["memory"]["qmd"]["provider"].as_str(), Some("qmd"));
assert_eq!(config["memory"]["provider"].as_str(), Some("honcho"));
assert_eq!(config["memory"]["custom_flag"].as_str(), Some("keep-me"));
}
@@ -24195,6 +24249,7 @@ mod hermes_security_config_tests {
assert_eq!(values["tirithPath"], "tirith");
assert_eq!(values["tirithTimeout"], 5);
assert_eq!(values["tirithFailOpen"], true);
assert_eq!(values["installPolicyJson"], "");
}
#[test]
@@ -24206,6 +24261,11 @@ security:
tirith_path: C:/tools/tirith.exe
tirith_timeout: 12
tirith_fail_open: false
installPolicy:
enabled: true
targets:
- skill
- plugin
"#,
)
.unwrap();
@@ -24214,6 +24274,10 @@ security:
assert_eq!(values["tirithPath"], "C:/tools/tirith.exe");
assert_eq!(values["tirithTimeout"], 12);
assert_eq!(values["tirithFailOpen"], false);
let install_policy: serde_json::Value =
serde_json::from_str(values["installPolicyJson"].as_str().unwrap()).unwrap();
assert_eq!(install_policy["enabled"], true);
assert_eq!(install_policy["targets"][0], "skill");
}
#[test]
@@ -24228,6 +24292,10 @@ security:
enabled: true
domains:
- example.com
installPolicy:
enabled: false
targets:
- skill
custom_flag: keep-security
terminal:
backend: docker
@@ -24242,6 +24310,7 @@ terminal:
"tirithPath": "~/bin/tirith",
"tirithTimeout": 9,
"tirithFailOpen": false,
"installPolicyJson": r#"{"enabled":true,"targets":["skill","plugin"],"exec":{"source":"exec","command":"tirith","args":["scan"]}}"#,
}),
)
.unwrap();
@@ -24252,6 +24321,18 @@ terminal:
config["security"]["custom_flag"].as_str(),
Some("keep-security")
);
assert_eq!(
config["security"]["installPolicy"]["enabled"].as_bool(),
Some(true)
);
assert_eq!(
config["security"]["installPolicy"]["targets"][1].as_str(),
Some("plugin")
);
assert_eq!(
config["security"]["installPolicy"]["exec"]["command"].as_str(),
Some("tirith")
);
assert_eq!(config["security"]["tirith_enabled"].as_bool(), Some(false));
assert_eq!(
config["security"]["tirith_path"].as_str(),
@@ -24274,6 +24355,10 @@ terminal:
let err =
merge_hermes_security_config(&mut config, &json!({ "tirithPath": "" })).unwrap_err();
assert!(err.contains("security.tirith_path"));
let err = merge_hermes_security_config(&mut config, &json!({ "installPolicyJson": "[]" }))
.unwrap_err();
assert!(err.contains("security.installPolicy"));
}
}

View File

@@ -297,7 +297,7 @@ pub fn auto_pair_device() -> Result<String, String> {
/// 将 Tauri 应用的 origin 写入 gateway.controlUi.allowedOrigins
/// 避免 Gateway 因 origin not allowed 拒绝 WebSocket 握手
fn patch_gateway_origins() {
let Ok(mut config) = super::config::load_openclaw_json() else {
let Ok(config) = super::config::load_openclaw_json() else {
return;
};
@@ -310,37 +310,39 @@ fn patch_gateway_origins() {
"http://127.0.0.1:1420".into(),
];
if let Some(obj) = config.as_object_mut() {
let gateway = obj
.entry("gateway")
.or_insert_with(|| serde_json::json!({}));
if let Some(gw) = gateway.as_object_mut() {
let control_ui = gw
.entry("controlUi")
.or_insert_with(|| serde_json::json!({}));
if let Some(cui) = control_ui.as_object_mut() {
// 合并:保留用户已有的 origin追加缺失的 Tauri origin
let existing: Vec<String> = cui
.get("allowedOrigins")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let mut merged = existing;
for r in &required {
if !merged.iter().any(|e| e == r) {
merged.push(r.clone());
}
}
cui.insert("allowedOrigins".to_string(), serde_json::json!(merged));
}
let existing: Vec<String> = config
.pointer("/gateway/controlUi/allowedOrigins")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
if required
.iter()
.all(|origin| existing.iter().any(|item| item == origin))
{
return;
}
let mut merged = existing;
for origin in &required {
if !merged.iter().any(|item| item == origin) {
merged.push(origin.clone());
}
}
let _ = super::config::save_openclaw_json(&config);
// 只写入 allowedOrigins 增量,避免用陈旧全量快照覆盖并发保存的其它配置字段。
let patch = serde_json::json!({
"gateway": {
"controlUi": {
"allowedOrigins": merged
}
}
});
let _ = super::config::save_openclaw_json(&patch);
}
#[tauri::command]

View File

@@ -152,7 +152,10 @@ fn parse_lsof_pid_output(text: &str) -> Option<u32> {
#[cfg(test)]
mod gateway_pid_parse_tests {
use super::{parse_lsof_pid_output, parse_ss_listen_pid_output};
use super::{
parse_lsof_pid_output, parse_ss_listen_pid_output,
should_preserve_plugin_config_on_generic_strip,
};
#[test]
fn parses_linux_ss_listener_pid() {
@@ -167,6 +170,15 @@ LISTEN 0 511 127.0.0.1:18789 0.0.0.0:* users:((\"node\",pid=
assert_eq!(parse_lsof_pid_output("4242\n"), Some(4242));
assert_eq!(parse_lsof_pid_output("not-a-pid\n4242\n"), Some(4242));
}
#[test]
fn generic_config_strip_preserves_parallel_plugin_config() {
assert!(should_preserve_plugin_config_on_generic_strip("parallel"));
assert!(should_preserve_plugin_config_on_generic_strip("Parallel"));
assert!(!should_preserve_plugin_config_on_generic_strip(
"legacy-plugin"
));
}
}
fn read_gateway_owner() -> Option<GatewayOwnerRecord> {
@@ -326,6 +338,14 @@ fn looks_like_gateway_config_mismatch(reason: &str) -> bool {
/// 直接修复 openclaw.json 中 plugins.entries.*.config 的多余属性
/// 当 `openclaw doctor --fix` 无法修复时作为二级回退
const GENERIC_CONFIG_STRIP_PROTECTED_PLUGINS: &[&str] = &["parallel"];
fn should_preserve_plugin_config_on_generic_strip(name: &str) -> bool {
GENERIC_CONFIG_STRIP_PROTECTED_PLUGINS
.iter()
.any(|id| id.eq_ignore_ascii_case(name))
}
fn try_direct_config_strip() -> Result<bool, String> {
let config_path = crate::commands::openclaw_dir().join("openclaw.json");
let raw =
@@ -372,6 +392,12 @@ fn try_direct_config_strip() -> Result<bool, String> {
{
let entry_names: Vec<String> = entries.keys().cloned().collect();
for name in &entry_names {
if should_preserve_plugin_config_on_generic_strip(name) {
guardian_log(&format!(
"直接修复(通用回退): 保留 plugins.entries.{name}.config"
));
continue;
}
if let Some(entry) = entries.get_mut(name) {
if let Some(obj) = entry.as_object_mut() {
if obj.contains_key("config") {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "ClawPanel",
"version": "0.18.2",
"version": "0.18.3",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",