fix(hermes): validate raw config saves

This commit is contained in:
晴天
2026-05-24 06:10:07 +08:00
parent ff4da27eeb
commit 4c29ed68ab
5 changed files with 109 additions and 11 deletions

View File

@@ -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() {

View File

@@ -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::{

View File

@@ -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')

View File

@@ -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 編輯器'),

View 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' },
})
})