mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
fix(hermes): validate raw config saves
This commit is contained in:
@@ -66,6 +66,23 @@ function hermesHome() {
|
||||
return process.env.HERMES_HOME || HERMES_HOME
|
||||
}
|
||||
|
||||
export function validateHermesConfigYamlText(yamlText = '') {
|
||||
const raw = String(yamlText || '')
|
||||
if (!raw.trim()) return {}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = YAML.parse(raw)
|
||||
} catch (err) {
|
||||
throw new Error(`config.yaml YAML 格式错误: ${err?.message || String(err)}`)
|
||||
}
|
||||
|
||||
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('config.yaml 顶层必须是对象')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** Resolve memory kind (memory|user|soul) → markdown file name. */
|
||||
function memoryFileName(kind) {
|
||||
switch (kind) {
|
||||
@@ -9915,10 +9932,16 @@ const handlers = {
|
||||
|
||||
hermes_config_raw_write({ yamlText } = {}) {
|
||||
const configPath = path.join(hermesHome(), 'config.yaml')
|
||||
const content = String(yamlText || '')
|
||||
validateHermesConfigYamlText(content)
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
||||
if (fs.existsSync(configPath)) fs.copyFileSync(configPath, `${configPath}.bak-${Math.floor(Date.now() / 1000)}`)
|
||||
fs.writeFileSync(configPath, yamlText || '')
|
||||
return { ok: true }
|
||||
let backup = ''
|
||||
if (fs.existsSync(configPath)) {
|
||||
backup = `${configPath}.bak-${Math.floor(Date.now() / 1000)}`
|
||||
fs.copyFileSync(configPath, backup)
|
||||
}
|
||||
fs.writeFileSync(configPath, content)
|
||||
return { ok: true, backup }
|
||||
},
|
||||
|
||||
hermes_dashboard_themes() {
|
||||
|
||||
@@ -7610,22 +7610,38 @@ pub fn hermes_config_raw_read() -> Result<Value, String> {
|
||||
Ok(serde_json::json!({ "yaml": yaml }))
|
||||
}
|
||||
|
||||
fn validate_hermes_config_raw_yaml(yaml_text: &str) -> Result<(), String> {
|
||||
if yaml_text.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let parsed: serde_yaml::Value =
|
||||
serde_yaml::from_str(yaml_text).map_err(|e| format!("config.yaml YAML 格式错误: {e}"))?;
|
||||
if parsed.as_mapping().is_none() {
|
||||
return Err("config.yaml 顶层必须是对象".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hermes_config_raw_write(yaml_text: String) -> Result<Value, String> {
|
||||
validate_hermes_config_raw_yaml(&yaml_text)?;
|
||||
let path = hermes_home().join("config.yaml");
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?;
|
||||
}
|
||||
let mut backup_path: Option<String> = None;
|
||||
if path.exists() {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let backup = path.with_extension(format!("yaml.bak-{ts}"));
|
||||
let _ = std::fs::copy(&path, backup);
|
||||
if std::fs::copy(&path, &backup).is_ok() {
|
||||
backup_path = Some(backup.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
std::fs::write(&path, yaml_text).map_err(|e| format!("Failed to write config.yaml: {e}"))?;
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
Ok(serde_json::json!({ "ok": true, "backup": backup_path.unwrap_or_default() }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -8512,6 +8528,31 @@ platforms:
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hermes_config_raw_tests {
|
||||
use super::validate_hermes_config_raw_yaml;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_raw_config_yaml_before_write() {
|
||||
let err =
|
||||
validate_hermes_config_raw_yaml("model:\n default: gpt-4o\n provider: openai\n")
|
||||
.unwrap_err();
|
||||
assert!(err.contains("config.yaml YAML 格式错误"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_object_raw_config_yaml_before_write() {
|
||||
let err = validate_hermes_config_raw_yaml("- model\n- display\n").unwrap_err();
|
||||
assert!(err.contains("config.yaml 顶层必须是对象"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_empty_and_mapping_raw_config_yaml() {
|
||||
validate_hermes_config_raw_yaml("").unwrap();
|
||||
validate_hermes_config_raw_yaml("model:\n default: gpt-4o\n").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hermes_channel_tests {
|
||||
use super::{
|
||||
|
||||
@@ -13,7 +13,7 @@ export function render() {
|
||||
let yaml = ''
|
||||
let loading = true
|
||||
let saving = false
|
||||
let error = ''
|
||||
let error = null
|
||||
|
||||
function esc(value) {
|
||||
return String(value || '')
|
||||
@@ -45,7 +45,11 @@ export function render() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-panel-body" style="padding:0">
|
||||
${error ? `<div style="margin:16px 18px;padding:10px 14px;border-radius:var(--hm-radius-sm);background:var(--hm-error-soft);color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12px">${esc(error)}</div>` : ''}
|
||||
${error ? `<div style="margin:16px 18px;padding:10px 14px;border-radius:var(--hm-radius-sm);background:var(--hm-error-soft);color:var(--hm-error);font-family:var(--hm-font-mono);font-size:12px;line-height:1.6">
|
||||
<div>${esc(error.message || error)}</div>
|
||||
${error.hint ? `<div style="margin-top:4px;color:var(--hm-text-tertiary)">${esc(error.hint)}</div>` : ''}
|
||||
${error.raw ? `<details style="margin-top:6px"><summary>${esc(t('common.errorRawLabel'))}</summary><pre style="white-space:pre-wrap;word-break:break-word;margin:6px 0 0">${esc(error.raw)}</pre></details>` : ''}
|
||||
</div>` : ''}
|
||||
<textarea id="hm-config-yaml" class="hm-input" spellcheck="false" ${loading || saving ? 'disabled' : ''} style="width:100%;min-height:560px;border:0;border-radius:0;background:var(--hm-surface-0);font-family:var(--hm-font-mono);font-size:12px;line-height:1.7;padding:18px 20px;resize:vertical">${esc(yaml)}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +60,7 @@ export function render() {
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
error = ''
|
||||
error = null
|
||||
draw()
|
||||
try {
|
||||
const data = await api.hermesConfigRawRead()
|
||||
@@ -73,11 +77,15 @@ export function render() {
|
||||
const textarea = el.querySelector('#hm-config-yaml')
|
||||
yaml = textarea?.value || ''
|
||||
saving = true
|
||||
error = ''
|
||||
error = null
|
||||
draw()
|
||||
try {
|
||||
await api.hermesConfigRawWrite(yaml)
|
||||
toast(t('engine.hermesConfigSaveSuccess'), 'success')
|
||||
const result = await api.hermesConfigRawWrite(yaml)
|
||||
const backup = result?.backup || ''
|
||||
toast({
|
||||
message: t('engine.hermesConfigSaveSuccess'),
|
||||
hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '',
|
||||
}, 'success')
|
||||
} catch (err) {
|
||||
error = humanizeError(err, t('engine.hermesConfigSaveFailed') || 'Save failed')
|
||||
toast(error, 'error')
|
||||
|
||||
@@ -476,6 +476,7 @@ export default {
|
||||
hermesConfigReload: _('重新加载', 'Reload', '重新載入'),
|
||||
hermesConfigSave: _('保存配置', 'Save', '儲存設定'),
|
||||
hermesConfigSaveSuccess: _('配置已保存,建议重启 Hermes Gateway 生效', 'Saved. Restart Hermes Gateway to take effect.', '已儲存設定,建議重啟 Hermes Gateway 生效'),
|
||||
hermesConfigBackupHint: _('已先创建备份:{path}', 'Backup created first: {path}', '已先建立備份:{path}'),
|
||||
hermesConfigStatusSaving: _('保存中…', 'Saving…', '儲存中…'),
|
||||
hermesConfigStatusLoading: _('加载中…', 'Loading…', '載入中…'),
|
||||
hermesConfigStatusReady: _('raw yaml 编辑器', 'raw yaml editor', 'raw yaml 編輯器'),
|
||||
|
||||
25
tests/hermes-config-raw.test.js
Normal file
25
tests/hermes-config-raw.test.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { validateHermesConfigYamlText } from '../scripts/dev-api.js'
|
||||
|
||||
test('Hermes 原始配置保存前会拒绝无效 YAML', () => {
|
||||
assert.throws(
|
||||
() => validateHermesConfigYamlText('model:\n default: gpt-4o\n provider: openai\n'),
|
||||
/config\.yaml YAML 格式错误/,
|
||||
)
|
||||
})
|
||||
|
||||
test('Hermes 原始配置保存前会拒绝非对象顶层 YAML', () => {
|
||||
assert.throws(
|
||||
() => validateHermesConfigYamlText('- model\n- display\n'),
|
||||
/config\.yaml 顶层必须是对象/,
|
||||
)
|
||||
})
|
||||
|
||||
test('Hermes 原始配置保存前允许空内容与对象顶层 YAML', () => {
|
||||
assert.deepEqual(validateHermesConfigYamlText(''), {})
|
||||
assert.deepEqual(validateHermesConfigYamlText('model:\n default: gpt-4o\n'), {
|
||||
model: { default: 'gpt-4o' },
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user