mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-03 13:41:28 +08:00
fix(openclaw): 兼容新版配置与 Node 门槛
This commit is contained in:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -366,7 +366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
edition = "2021"
|
||||
description = "ClawPanel - OpenClaw 可视化管理面板"
|
||||
authors = ["qingchencloud"]
|
||||
|
||||
@@ -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.js:https://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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user