From 59d78332031fd23bc68413cd5567e0455e84b51c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Tue, 26 May 2026 04:04:22 +0800 Subject: [PATCH] feat(hermes): add shell hooks config --- scripts/dev-api.js | 109 +++++++++ src-tauri/src/commands/hermes.rs | 346 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 95 +++++++- src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 10 + tests/hermes-config-page-ui.test.js | 11 + tests/hermes-hooks-config.test.js | 129 +++++++++++ 8 files changed, 699 insertions(+), 5 deletions(-) create mode 100644 tests/hermes-hooks-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index f6ac033..104120e 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3345,6 +3345,19 @@ const HERMES_DISPLAY_BACKGROUND_PROCESS_NOTIFICATIONS = new Set(['off', 'result' const HERMES_DISPLAY_FINAL_RESPONSE_MARKDOWN_VALUES = new Set(['render', 'strip', 'raw']) const HERMES_DISPLAY_LANGUAGE_VALUES = new Set(['en', 'zh', 'zh-hant', 'ja', 'de', 'es', 'fr', 'tr', 'uk', 'af', 'ko', 'it', 'ga', 'pt', 'ru', 'hu']) const HERMES_RUNTIME_FOOTER_FIELDS = new Set(['model', 'context_pct', 'cwd', 'duration', 'tokens', 'cost']) +const HERMES_HOOK_EVENTS = new Set([ + 'pre_tool_call', + 'post_tool_call', + 'pre_llm_call', + 'post_llm_call', + 'pre_api_request', + 'post_api_request', + 'on_session_start', + 'on_session_end', + 'on_session_finalize', + 'on_session_reset', + 'subagent_stop', +]) const HERMES_DEFAULT_PLATFORM_TOOLSETS = { cli: ['hermes-cli'], telegram: ['hermes-telegram'], @@ -4209,6 +4222,81 @@ export function mergeHermesQuickCommandsConfig(config = {}, form = {}) { return next } +function normalizeHermesHookTimeout(entry, key) { + if (!Object.hasOwn(entry, 'timeout') || entry.timeout == null || entry.timeout === '') { + delete entry.timeout + return + } + entry.timeout = parseHermesInteger(entry.timeout, `${key}.timeout`, 30, 1, 86400, true) +} + +function validateHermesHooks(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('hooks 必须是 JSON 对象') + } + const normalized = {} + for (const [rawEvent, rawEntries] of Object.entries(value)) { + const event = String(rawEvent || '').trim() + if (!HERMES_HOOK_EVENTS.has(event)) { + throw new Error(`hooks.${event || ''} 事件名不受支持`) + } + if (!Array.isArray(rawEntries)) { + throw new Error(`hooks.${event} 必须是数组`) + } + const entries = rawEntries.map((rawEntry, index) => { + const key = `hooks.${event}.${index}` + if (!rawEntry || typeof rawEntry !== 'object' || Array.isArray(rawEntry)) { + throw new Error(`${key} 必须是 JSON 对象`) + } + const entry = mergeConfigsPreservingFields(rawEntry, {}) + const command = typeof entry.command === 'string' ? entry.command.trim() : '' + if (!command) throw new Error(`${key}.command 不能为空`) + entry.command = command + if (Object.hasOwn(entry, 'matcher') && entry.matcher != null) { + if (typeof entry.matcher !== 'string') throw new Error(`${key}.matcher 必须是字符串`) + entry.matcher = entry.matcher.trim() + } + normalizeHermesHookTimeout(entry, key) + return entry + }) + if (entries.length) normalized[event] = entries + } + return normalized +} + +function parseHermesHooksJson(raw) { + const text = String(raw ?? '').trim() + if (!text) return {} + let value + try { + value = JSON.parse(text) + } catch (err) { + throw new Error(`hooks JSON 格式错误: ${err.message}`) + } + return validateHermesHooks(value) +} + +export function buildHermesHooksConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const hooks = root.hooks && typeof root.hooks === 'object' && !Array.isArray(root.hooks) + ? validateHermesHooks(root.hooks) + : {} + return { + hooksAutoAccept: readHermesBool(root.hooks_auto_accept, false), + hooksJson: JSON.stringify(hooks, null, 2), + } +} + +export function mergeHermesHooksConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesHooksConfigValues(next) + const hooks = parseHermesHooksJson(Object.hasOwn(form, 'hooksJson') ? form.hooksJson : currentValues.hooksJson) + next.hooks_auto_accept = formHermesBool(form, 'hooksAutoAccept', currentValues.hooksAutoAccept) + if (Object.keys(hooks).length) next.hooks = hooks + else delete next.hooks + return next +} + function normalizeHermesMcpServerName(value) { const name = String(value ?? '').trim() if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) { @@ -11347,6 +11435,27 @@ const handlers = { } }, + hermes_hooks_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesHooksConfigValues(config), + } + }, + + hermes_hooks_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesHooksConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesHooksConfigValues(next), + } + }, + hermes_provider_overrides_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 198860f..9fc1664 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -4407,6 +4407,155 @@ fn merge_hermes_quick_commands_config( Ok(()) } +fn is_hermes_hook_event(value: &str) -> bool { + matches!( + value, + "pre_tool_call" + | "post_tool_call" + | "pre_llm_call" + | "post_llm_call" + | "pre_api_request" + | "post_api_request" + | "on_session_start" + | "on_session_end" + | "on_session_finalize" + | "on_session_reset" + | "subagent_stop" + ) +} + +fn normalize_hermes_hook_timeout( + entry: &mut serde_json::Map, + key: &str, +) -> Result<(), String> { + if !entry.contains_key("timeout") + || entry.get("timeout").is_some_and(|value| { + value.is_null() || value.as_str().is_some_and(|text| text.trim().is_empty()) + }) + { + entry.remove("timeout"); + return Ok(()); + } + let value = entry.get("timeout").cloned().unwrap_or(Value::Null); + let parsed = if let Some(value) = value.as_i64() { + Some(value) + } else if let Some(value) = value.as_u64() { + i64::try_from(value).ok() + } else if let Some(value) = value.as_str() { + value.trim().parse::().ok() + } else { + None + }; + let parsed = parsed.ok_or_else(|| format!("{key}.timeout 必须是整数"))?; + let parsed = validate_hermes_i64(Some(parsed), &format!("{key}.timeout"), 30, 1, 86400)?; + entry.insert("timeout".to_string(), Value::Number(parsed.into())); + Ok(()) +} + +fn validate_hermes_hooks(value: &Value) -> Result, String> { + let Some(map) = value.as_object() else { + return Err("hooks 必须是 JSON 对象".to_string()); + }; + let mut normalized = serde_json::Map::new(); + for (raw_event, raw_entries) in map { + let event = raw_event.trim(); + if !is_hermes_hook_event(event) { + return Err(format!( + "hooks.{} 事件名不受支持", + if event.is_empty() { + "" + } else { + raw_event + } + )); + } + let Some(entries) = raw_entries.as_array() else { + return Err(format!("hooks.{event} 必须是数组")); + }; + let mut normalized_entries = Vec::new(); + for (index, raw_entry) in entries.iter().enumerate() { + let key = format!("hooks.{event}.{index}"); + let Some(config) = raw_entry.as_object() else { + return Err(format!("{key} 必须是 JSON 对象")); + }; + let mut entry = config.clone(); + let command = entry + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim() + .to_string(); + if command.is_empty() { + return Err(format!("{key}.command 不能为空")); + } + entry.insert("command".to_string(), Value::String(command)); + if let Some(matcher) = entry.get("matcher") { + let Some(matcher) = matcher.as_str() else { + return Err(format!("{key}.matcher 必须是字符串")); + }; + entry.insert( + "matcher".to_string(), + Value::String(matcher.trim().to_string()), + ); + } + normalize_hermes_hook_timeout(&mut entry, &key)?; + normalized_entries.push(Value::Object(entry)); + } + if !normalized_entries.is_empty() { + normalized.insert(event.to_string(), Value::Array(normalized_entries)); + } + } + Ok(normalized) +} + +fn parse_hermes_hooks_json(raw: Option) -> Result, String> { + let text = raw.unwrap_or_default().trim().to_string(); + if text.is_empty() { + return Ok(serde_json::Map::new()); + } + let value: Value = + serde_json::from_str(&text).map_err(|err| format!("hooks JSON 格式错误: {err}"))?; + validate_hermes_hooks(&value) +} + +fn build_hermes_hooks_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let hooks = root + .and_then(|map| map.get(yaml_key("hooks"))) + .and_then(|value| serde_json::to_value(value).ok()) + .and_then(|value| validate_hermes_hooks(&value).ok()) + .unwrap_or_default(); + + serde_json::json!({ + "hooksAutoAccept": root.and_then(|map| yaml_bool_field(map, "hooks_auto_accept")).unwrap_or(false), + "hooksJson": serde_json::to_string_pretty(&Value::Object(hooks)).unwrap_or_else(|_| "{}".to_string()), + }) +} + +fn merge_hermes_hooks_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> { + let current = build_hermes_hooks_config_values(config); + let hooks = parse_hermes_hooks_json( + form_string(form, "hooksJson") + .or_else(|| current["hooksJson"].as_str().map(ToString::to_string)), + )?; + let hooks_auto_accept = form_bool(form, "hooksAutoAccept") + .unwrap_or_else(|| current["hooksAutoAccept"].as_bool().unwrap_or(false)); + + let root = ensure_yaml_object(config)?; + root.insert( + yaml_key("hooks_auto_accept"), + serde_yaml::Value::Bool(hooks_auto_accept), + ); + if hooks.is_empty() { + root.remove(yaml_key("hooks")); + } else { + let yaml_value = serde_yaml::to_value(Value::Object(hooks)) + .map_err(|err| format!("hooks 转换 YAML 失败: {err}"))?; + root.insert(yaml_key("hooks"), yaml_value); + } + Ok(()) +} + fn is_hermes_mcp_server_name(value: &str) -> bool { let value = value.trim(); !value.is_empty() @@ -8267,6 +8416,30 @@ pub fn hermes_quick_commands_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_hooks_config_read() -> Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + ensure_yaml_object(&mut config.clone())?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_hooks_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_hooks_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_hooks_config(&mut config, &form)?; + let backup = write_hermes_yaml_config(&config_path, &config)?; + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "backup": backup, + "values": build_hermes_hooks_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_provider_overrides_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -16033,6 +16206,179 @@ streaming: } } +#[cfg(test)] +mod hermes_hooks_config_tests { + use super::{build_hermes_hooks_config_values, merge_hermes_hooks_config}; + use serde_json::json; + + #[test] + fn hooks_values_have_safe_defaults() { + let config = serde_yaml::Value::Mapping(Default::default()); + let values = build_hermes_hooks_config_values(&config); + + assert_eq!(values["hooksAutoAccept"], false); + assert_eq!(values["hooksJson"], "{}"); + } + + #[test] + fn hooks_values_read_yaml_mapping() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +hooks_auto_accept: true +hooks: + pre_tool_call: + - matcher: terminal + command: ~/.hermes/agent-hooks/block-rm-rf.sh + timeout: 10 + pre_llm_call: + - command: ~/.hermes/agent-hooks/inject-cwd-context.sh +"#, + ) + .unwrap(); + + let values = build_hermes_hooks_config_values(&config); + let hooks: serde_json::Value = + serde_json::from_str(values["hooksJson"].as_str().unwrap()).unwrap(); + + assert_eq!(values["hooksAutoAccept"], true); + assert_eq!(hooks["pre_tool_call"][0]["matcher"], "terminal"); + assert_eq!( + hooks["pre_tool_call"][0]["command"], + "~/.hermes/agent-hooks/block-rm-rf.sh" + ); + assert_eq!(hooks["pre_tool_call"][0]["timeout"], 10); + assert_eq!( + hooks["pre_llm_call"][0]["command"], + "~/.hermes/agent-hooks/inject-cwd-context.sh" + ); + } + + #[test] + fn merge_hooks_config_preserves_unknown_fields_and_unrelated_yaml() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: openrouter +hooks: + pre_tool_call: + - matcher: terminal + command: old-hook.sh + extra_flag: keep-old +memory: + memory_enabled: true +"#, + ) + .unwrap(); + + merge_hermes_hooks_config( + &mut config, + &json!({ + "hooksAutoAccept": "true", + "hooksJson": serde_json::to_string(&json!({ + "pre_tool_call": [{ + "matcher": "terminal", + "command": "~/.hermes/agent-hooks/block-rm-rf.sh", + "timeout": 10, + "extra_flag": "keep-hook" + }], + "post_tool_call": [{ + "matcher": "write_file|patch", + "command": "~/.hermes/agent-hooks/auto-format.sh" + }] + })).unwrap(), + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("openrouter")); + assert_eq!(config["memory"]["memory_enabled"].as_bool(), Some(true)); + assert_eq!(config["hooks_auto_accept"].as_bool(), Some(true)); + assert_eq!( + config["hooks"]["pre_tool_call"][0]["command"].as_str(), + Some("~/.hermes/agent-hooks/block-rm-rf.sh") + ); + assert_eq!( + config["hooks"]["pre_tool_call"][0]["timeout"].as_i64(), + Some(10) + ); + assert_eq!( + config["hooks"]["pre_tool_call"][0]["extra_flag"].as_str(), + Some("keep-hook") + ); + assert_eq!( + config["hooks"]["post_tool_call"][0]["matcher"].as_str(), + Some("write_file|patch") + ); + } + + #[test] + fn merge_hooks_config_removes_empty_mapping_but_keeps_auto_accept() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +hooks_auto_accept: true +hooks: + pre_tool_call: + - command: old-hook.sh +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_hooks_config( + &mut config, + &json!({ "hooksAutoAccept": false, "hooksJson": "{}" }), + ) + .unwrap(); + + assert!(config["hooks"].is_null()); + assert_eq!(config["hooks_auto_accept"].as_bool(), Some(false)); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + } + + #[test] + fn merge_hooks_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(Default::default()); + let err = merge_hermes_hooks_config(&mut config, &json!({ "hooksJson": "[" })).unwrap_err(); + assert!(err.contains("hooks JSON")); + + let err = merge_hermes_hooks_config( + &mut config, + &json!({ "hooksJson": serde_json::to_string(&json!({ "bad_event": [{ "command": "hook.sh" }] })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("hooks.bad_event")); + + let err = merge_hermes_hooks_config( + &mut config, + &json!({ "hooksJson": serde_json::to_string(&json!({ "pre_tool_call": { "command": "hook.sh" } })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("hooks.pre_tool_call")); + + let err = merge_hermes_hooks_config( + &mut config, + &json!({ "hooksJson": serde_json::to_string(&json!({ "pre_tool_call": ["hook.sh"] })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("hooks.pre_tool_call.0")); + + let err = merge_hermes_hooks_config( + &mut config, + &json!({ "hooksJson": serde_json::to_string(&json!({ "pre_tool_call": [{ "command": "" }] })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("hooks.pre_tool_call.0.command")); + + let err = merge_hermes_hooks_config( + &mut config, + &json!({ "hooksJson": serde_json::to_string(&json!({ "pre_tool_call": [{ "command": "hook.sh", "timeout": 0 }] })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("hooks.pre_tool_call.0.timeout")); + } +} + #[cfg(test)] mod hermes_mcp_servers_config_tests { use super::{build_hermes_mcp_servers_config_values, merge_hermes_mcp_servers_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 96dc8d4..7431501 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -277,6 +277,8 @@ pub fn run() { hermes::hermes_skills_config_save, hermes::hermes_quick_commands_config_read, hermes::hermes_quick_commands_config_save, + hermes::hermes_hooks_config_read, + hermes::hermes_hooks_config_save, hermes::hermes_provider_overrides_config_read, hermes::hermes_provider_overrides_config_save, hermes::hermes_mcp_servers_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 16032d3..34b7199 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -83,6 +83,11 @@ const QUICK_COMMANDS_DEFAULTS = { quickCommandsJson: '{}', } +const HOOKS_DEFAULTS = { + hooksAutoAccept: false, + hooksJson: '{}', +} + const PROVIDER_OVERRIDES_DEFAULTS = { providerOverridesJson: '{}', } @@ -281,6 +286,7 @@ export function render() { let memoryValues = { ...MEMORY_DEFAULTS } let skillsValues = { ...SKILLS_DEFAULTS } let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS } + let hooksValues = { ...HOOKS_DEFAULTS } let providerOverridesValues = { ...PROVIDER_OVERRIDES_DEFAULTS } let mcpServersValues = { ...MCP_SERVERS_DEFAULTS } let agentToolsetsValues = { ...AGENT_TOOLSETS_DEFAULTS } @@ -312,6 +318,7 @@ export function render() { let memoryLoading = true let skillsLoading = true let quickCommandsLoading = true + let hooksLoading = true let providerOverridesLoading = true let mcpServersLoading = true let agentToolsetsLoading = true @@ -343,6 +350,7 @@ export function render() { let memorySaving = false let skillsSaving = false let quickCommandsSaving = false + let hooksSaving = false let providerOverridesSaving = false let mcpServersSaving = false let agentToolsetsSaving = false @@ -374,6 +382,7 @@ export function render() { let memoryError = null let skillsError = null let quickCommandsError = null + let hooksError = null let providerOverridesError = null let mcpServersError = null let agentToolsetsError = null @@ -404,7 +413,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving } function option(labelKey, value, selected) { @@ -866,7 +875,7 @@ export function render() { } function renderQuickCommandsConfigPanel() { - const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -891,8 +900,38 @@ export function render() { ` } + function renderHooksConfigPanel() { + const disabled = loading || saving || hooksLoading || hooksSaving || quickCommandsSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesHooksConfigTitle')}
+
${t('engine.hermesHooksConfigDesc')}
+
+
+ ${hooksSaving ? t('engine.hermesConfigStatusSaving') : hooksLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesHooksConfigStatusReady')} + +
+
+
+ ${renderError(hooksError)} + + +
${t('engine.hermesHooksConfigFootnote')}
+
+
+ ` + } + function renderProviderOverridesConfigPanel() { - const disabled = loading || saving || providerOverridesLoading || providerOverridesSaving || quickCommandsSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || providerOverridesLoading || providerOverridesSaving || quickCommandsSaving || hooksSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -918,7 +957,7 @@ export function render() { } function renderMcpServersConfigPanel() { - const disabled = loading || saving || mcpServersLoading || mcpServersSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || mcpServersLoading || mcpServersSaving || quickCommandsSaving || hooksSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -944,7 +983,7 @@ export function render() { } function renderAgentToolsetsConfigPanel() { - const disabled = loading || saving || agentToolsetsLoading || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || mcpServersSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || agentToolsetsLoading || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -1855,6 +1894,7 @@ export function render() { ${renderMemoryPanel()} ${renderSkillsConfigPanel()} ${renderQuickCommandsConfigPanel()} + ${renderHooksConfigPanel()} ${renderProviderOverridesConfigPanel()} ${renderMcpServersConfigPanel()} ${renderAgentToolsetsConfigPanel()} @@ -1893,6 +1933,7 @@ export function render() { el.querySelector('#hm-memory-save')?.addEventListener('click', saveMemory) el.querySelector('#hm-skills-config-save')?.addEventListener('click', saveSkillsConfig) el.querySelector('#hm-quick-commands-save')?.addEventListener('click', saveQuickCommandsConfig) + el.querySelector('#hm-hooks-save')?.addEventListener('click', saveHooksConfig) el.querySelector('#hm-provider-overrides-save')?.addEventListener('click', saveProviderOverridesConfig) el.querySelector('#hm-mcp-servers-save')?.addEventListener('click', saveMcpServersConfig) el.querySelector('#hm-agent-toolsets-save')?.addEventListener('click', saveAgentToolsetsConfig) @@ -1970,6 +2011,11 @@ export function render() { quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS, ...(data?.values || {}) } } + async function loadHooksConfig() { + const data = await api.hermesHooksConfigRead() + hooksValues = { ...HOOKS_DEFAULTS, ...(data?.values || {}) } + } + async function loadProviderOverridesConfig() { const data = await api.hermesProviderOverridesConfigRead() providerOverridesValues = { ...PROVIDER_OVERRIDES_DEFAULTS, ...(data?.values || {}) } @@ -2082,6 +2128,7 @@ export function render() { memoryLoading = true skillsLoading = true quickCommandsLoading = true + hooksLoading = true providerOverridesLoading = true mcpServersLoading = true agentToolsetsLoading = true @@ -2113,6 +2160,7 @@ export function render() { memoryError = null skillsError = null quickCommandsError = null + hooksError = null providerOverridesError = null mcpServersError = null agentToolsetsError = null @@ -2309,6 +2357,14 @@ export function render() { quickCommandsLoading = false draw() } + try { + await loadHooksConfig() + } catch (err) { + hooksError = humanizeError(err, t('engine.hermesHooksConfigLoadFailed') || 'Load hooks config failed') + } finally { + hooksLoading = false + draw() + } try { await loadProviderOverridesConfig() } catch (err) { @@ -2432,6 +2488,9 @@ export function render() { try { await loadQuickCommandsConfig() } catch {} + try { + await loadHooksConfig() + } catch {} try { await loadProviderOverridesConfig() } catch {} @@ -2785,6 +2844,32 @@ export function render() { } } + async function saveHooksConfig() { + const form = { + hooksAutoAccept: !!el.querySelector('#hm-hooks-auto-accept')?.checked, + hooksJson: el.querySelector('#hm-hooks-json')?.value || '{}', + } + hooksSaving = true + hooksError = null + draw() + try { + const result = await api.hermesHooksConfigSave(form) + hooksValues = { ...HOOKS_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesHooksConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + hooksError = humanizeError(err, t('engine.hermesHooksConfigSaveFailed') || 'Save hooks config failed') + toast(hooksError, 'error') + } finally { + hooksSaving = false + draw() + } + } + async function saveProviderOverridesConfig() { const form = { providerOverridesJson: el.querySelector('#hm-provider-overrides-json')?.value || '{}', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index d95354c..725d4b5 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -529,6 +529,8 @@ export const api = { hermesSkillsConfigSave: (form) => invoke('hermes_skills_config_save', { form }), hermesQuickCommandsConfigRead: () => invoke('hermes_quick_commands_config_read'), hermesQuickCommandsConfigSave: (form) => invoke('hermes_quick_commands_config_save', { form }), + hermesHooksConfigRead: () => invoke('hermes_hooks_config_read'), + hermesHooksConfigSave: (form) => invoke('hermes_hooks_config_save', { form }), hermesProviderOverridesConfigRead: () => invoke('hermes_provider_overrides_config_read'), hermesProviderOverridesConfigSave: (form) => invoke('hermes_provider_overrides_config_save', { form }), hermesMcpServersConfigRead: () => invoke('hermes_mcp_servers_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 70b3ac4..2f33c7d 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -823,6 +823,16 @@ export default { hermesQuickCommandsConfigSaveFailed: _('保存快捷命令失败', 'Save quick commands failed', '儲存快捷命令失敗'), hermesQuickCommandsConfigJson: _('quick_commands JSON 映射', 'quick_commands JSON map', 'quick_commands JSON 映射'), hermesQuickCommandsConfigFootnote: _('键名会变成斜杠命令,例如 status 对应 /status。每个命令必须是对象,type 只能为 exec 或 alias;exec 需要 command,alias 的 target 必须以 / 开头。', 'Keys become slash commands, for example status maps to /status. Each command must be an object with type exec or alias; exec needs command, and alias target must start with /.', '鍵名會變成斜線命令,例如 status 對應 /status。每個命令必須是物件,type 只能是 exec 或 alias;exec 需要 command,alias 的 target 必須以 / 開頭。'), + hermesHooksConfigTitle: _('Shell Hooks', 'Shell hooks', 'Shell Hooks'), + hermesHooksConfigDesc: _('配置 Hermes 在工具调用、模型调用和会话生命周期中执行的本地脚本。请只添加可信脚本,自动接受会跳过首次确认。', 'Configure local scripts Hermes runs during tool calls, model calls, and session lifecycle events. Only add trusted scripts; auto accept skips first-use confirmation.', '設定 Hermes 在工具呼叫、模型呼叫和工作階段生命週期中執行的本機腳本。請只加入可信腳本,自動接受會略過首次確認。'), + hermesHooksConfigStatusReady: _('结构化 JSON', 'structured JSON', '結構化 JSON'), + hermesHooksConfigSave: _('保存 Hooks', 'Save hooks', '儲存 Hooks'), + hermesHooksConfigSaveSuccess: _('Hooks 配置已保存,建议重启 Hermes Gateway 生效', 'Hook settings saved. Restart Hermes Gateway to take effect.', 'Hooks 設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesHooksConfigLoadFailed: _('加载 Hooks 配置失败', 'Load hook settings failed', '載入 Hooks 設定失敗'), + hermesHooksConfigSaveFailed: _('保存 Hooks 配置失败', 'Save hook settings failed', '儲存 Hooks 設定失敗'), + hermesHooksConfigAutoAccept: _('自动接受已配置 Hooks(仅限可信脚本)', 'Auto accept configured hooks, trusted scripts only', '自動接受已設定 Hooks(僅限可信腳本)'), + hermesHooksConfigJson: _('hooks JSON 映射', 'hooks JSON map', 'hooks JSON 映射'), + hermesHooksConfigFootnote: _('键名必须是合法事件,例如 pre_tool_call、post_tool_call、pre_llm_call 或 subagent_stop。每项必须包含 command,可选 matcher 和 timeout;未知字段会保留在 raw YAML 中。', 'Keys must be valid events such as pre_tool_call, post_tool_call, pre_llm_call, or subagent_stop. Each item needs command, with optional matcher and timeout. Unknown fields stay in raw YAML.', '鍵名必須是合法事件,例如 pre_tool_call、post_tool_call、pre_llm_call 或 subagent_stop。每項必須包含 command,可選 matcher 和 timeout;未知欄位會保留在 raw YAML 中。'), hermesProviderOverridesConfigTitle: _('Provider 超时覆盖', 'Provider timeout overrides', 'Provider 逾時覆蓋'), hermesProviderOverridesConfigDesc: _('为指定 provider 或模型单独设置请求超时和非流式卡死检测,适合本地模型冷启动、慢速大上下文和云端快速失败策略。', 'Set request timeouts and non-streaming stale detection per provider or model. Useful for local cold starts, slow large contexts, and fast-fail cloud routes.', '為指定 provider 或模型單獨設定請求逾時和非串流卡死偵測,適合本地模型冷啟動、慢速大上下文和雲端快速失敗策略。'), hermesProviderOverridesConfigStatusReady: _('结构化 JSON', 'structured JSON', '結構化 JSON'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 71a4c1a..e4c5877 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -76,6 +76,16 @@ test('Hermes 配置页会暴露 MCP 服务结构化配置字段', () => { } }) +test('Hermes 配置页会暴露 Hooks 结构化配置字段', () => { + for (const id of [ + 'hm-hooks-save', + 'hm-hooks-auto-accept', + 'hm-hooks-json', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露全局禁用工具集结构化配置字段', () => { for (const id of [ 'hm-agent-toolsets-save', @@ -371,6 +381,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('QuickCommandsConfig') || key.includes('ProviderOverridesConfig') || key.includes('McpServersConfig') || + key.includes('HooksConfig') || key.includes('AgentToolsetsConfig') || key.includes('AgentRuntimeConfig') || key.includes('UnauthorizedDmConfig') || diff --git a/tests/hermes-hooks-config.test.js b/tests/hermes-hooks-config.test.js new file mode 100644 index 0000000..93511a0 --- /dev/null +++ b/tests/hermes-hooks-config.test.js @@ -0,0 +1,129 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesHooksConfigValues, + mergeHermesHooksConfig, +} from '../scripts/dev-api.js' + +test('Hermes hooks 配置读取会提供安全默认值', () => { + const values = buildHermesHooksConfigValues({}) + + assert.deepEqual(values, { + hooksAutoAccept: false, + hooksJson: '{}', + }) +}) + +test('Hermes hooks 配置读取会格式化事件映射', () => { + const values = buildHermesHooksConfigValues({ + hooks_auto_accept: true, + hooks: { + pre_tool_call: [ + { + matcher: 'terminal', + command: '~/.hermes/agent-hooks/block-rm-rf.sh', + timeout: 10, + }, + ], + pre_llm_call: [ + { + command: '~/.hermes/agent-hooks/inject-cwd-context.sh', + }, + ], + }, + }) + const hooks = JSON.parse(values.hooksJson) + + assert.equal(values.hooksAutoAccept, true) + assert.equal(hooks.pre_tool_call[0].matcher, 'terminal') + assert.equal(hooks.pre_tool_call[0].command, '~/.hermes/agent-hooks/block-rm-rf.sh') + assert.equal(hooks.pre_tool_call[0].timeout, 10) + assert.equal(hooks.pre_llm_call[0].command, '~/.hermes/agent-hooks/inject-cwd-context.sh') +}) + +test('Hermes hooks 配置保存会保留未知字段并写入 hooks', () => { + const next = mergeHermesHooksConfig({ + model: { provider: 'openrouter' }, + hooks: { + pre_tool_call: [ + { + matcher: 'terminal', + command: 'old-hook.sh', + extra_flag: 'keep-old', + }, + ], + }, + memory: { memory_enabled: true }, + }, { + hooksAutoAccept: 'true', + hooksJson: JSON.stringify({ + pre_tool_call: [ + { + matcher: 'terminal', + command: '~/.hermes/agent-hooks/block-rm-rf.sh', + timeout: 10, + extra_flag: 'keep-hook', + }, + ], + post_tool_call: [ + { + matcher: 'write_file|patch', + command: '~/.hermes/agent-hooks/auto-format.sh', + }, + ], + }), + }) + + assert.deepEqual(next.model, { provider: 'openrouter' }) + assert.deepEqual(next.memory, { memory_enabled: true }) + assert.equal(next.hooks_auto_accept, true) + assert.equal(next.hooks.pre_tool_call[0].command, '~/.hermes/agent-hooks/block-rm-rf.sh') + assert.equal(next.hooks.pre_tool_call[0].timeout, 10) + assert.equal(next.hooks.pre_tool_call[0].extra_flag, 'keep-hook') + assert.equal(next.hooks.post_tool_call[0].matcher, 'write_file|patch') +}) + +test('Hermes hooks 配置保存空对象会移除 hooks 但保留自动接受开关', () => { + const next = mergeHermesHooksConfig({ + hooks_auto_accept: true, + hooks: { + pre_tool_call: [{ command: 'old-hook.sh' }], + }, + streaming: { enabled: true }, + }, { + hooksAutoAccept: false, + hooksJson: '{}', + }) + + assert.equal(next.hooks, undefined) + assert.equal(next.hooks_auto_accept, false) + assert.deepEqual(next.streaming, { enabled: true }) +}) + +test('Hermes hooks 配置保存会拒绝非法 JSON、事件、结构、命令和超时', () => { + assert.throws( + () => mergeHermesHooksConfig({}, { hooksJson: '[' }), + /hooks JSON/, + ) + assert.throws( + () => mergeHermesHooksConfig({}, { hooksJson: JSON.stringify({ bad_event: [{ command: 'hook.sh' }] }) }), + /hooks\.bad_event/, + ) + assert.throws( + () => mergeHermesHooksConfig({}, { hooksJson: JSON.stringify({ pre_tool_call: { command: 'hook.sh' } }) }), + /hooks\.pre_tool_call/, + ) + assert.throws( + () => mergeHermesHooksConfig({}, { hooksJson: JSON.stringify({ pre_tool_call: ['hook.sh'] }) }), + /hooks\.pre_tool_call\.0/, + ) + assert.throws( + () => mergeHermesHooksConfig({}, { hooksJson: JSON.stringify({ pre_tool_call: [{ command: '' }] }) }), + /hooks\.pre_tool_call\.0\.command/, + ) + assert.throws( + () => mergeHermesHooksConfig({}, { hooksJson: JSON.stringify({ pre_tool_call: [{ command: 'hook.sh', timeout: 0 }] }) }), + /hooks\.pre_tool_call\.0\.timeout/, + ) +})