From 4c29ed68abe773d88bf8ed90b02a60858644a848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 24 May 2026 06:10:07 +0800 Subject: [PATCH] fix(hermes): validate raw config saves --- scripts/dev-api.js | 29 +++++++++++++++++-- src-tauri/src/commands/hermes.rs | 45 ++++++++++++++++++++++++++++-- src/engines/hermes/pages/config.js | 20 +++++++++---- src/locales/modules/engine.js | 1 + tests/hermes-config-raw.test.js | 25 +++++++++++++++++ 5 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 tests/hermes-config-raw.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index eba38da..478417f 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -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() { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 0d5c162..99cef1c 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -7610,22 +7610,38 @@ pub fn hermes_config_raw_read() -> Result { 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 { + 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 = 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::{ diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 264fc94..a3c32bf 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -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() {
- ${error ? `
${esc(error)}
` : ''} + ${error ? `
+
${esc(error.message || error)}
+ ${error.hint ? `
${esc(error.hint)}
` : ''} + ${error.raw ? `
${esc(t('common.errorRawLabel'))}
${esc(error.raw)}
` : ''} +
` : ''}
@@ -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') diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index e2f534d..4b5d4b2 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -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 編輯器'), diff --git a/tests/hermes-config-raw.test.js b/tests/hermes-config-raw.test.js new file mode 100644 index 0000000..dea8493 --- /dev/null +++ b/tests/hermes-config-raw.test.js @@ -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' }, + }) +})