From f7518ae4b3933f7304086425ee3f3e97739045ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 23 May 2026 02:49:31 +0800 Subject: [PATCH] fix(hermes): align channel secrets with runtime env --- scripts/dev-api.js | 58 ++++-- src-tauri/src/commands/hermes.rs | 252 ++++++++++++++++++++++---- src/engines/hermes/index.js | 8 +- src/engines/hermes/pages/channels.js | 2 +- src/locales/modules/engine.js | 3 +- tests/hermes-channel-config.test.js | 71 +++++++- tests/hermes-engine-listeners.test.js | 26 +++ 7 files changed, 361 insertions(+), 59 deletions(-) create mode 100644 tests/hermes-engine-listeners.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index a925dbc..2061fac 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2707,6 +2707,22 @@ function putHermesCsv(form, source, key) { if (value) form[toCamelCaseKey(key)] = value } +function hermesEnvValue(envValues, key) { + const value = envValues && Object.hasOwn(envValues, key) ? envValues[key] : undefined + return typeof value === 'string' && value.trim() ? value.trim() : '' +} + +function readHermesEnvValues() { + const envPath = path.join(hermesHome(), '.env') + const values = {} + if (!fs.existsSync(envPath)) return values + for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) { + const parsed = parseDotenvLine(line) + if (parsed && values[parsed[0]] === undefined) values[parsed[0]] = parsed[1] + } + return values +} + function normalizeHermesDmPolicy(raw) { const value = String(raw || '').trim().toLowerCase() if (value === 'pairing') return 'pair' @@ -2732,24 +2748,30 @@ function readHermesPlatform(config, platform) { return { entry, extra } } -export function buildHermesChannelConfigValues(config = {}) { +export function buildHermesChannelConfigValues(config = {}, envValues = {}) { const values = {} for (const platform of HERMES_CHANNEL_PLATFORMS) { const { entry, extra } = readHermesPlatform(config, platform) const form = { enabled: entry.enabled === true } if (platform === 'telegram') { - form.botToken = typeof entry.token === 'string' ? entry.token : '' + form.botToken = hermesEnvValue(envValues, 'TELEGRAM_BOT_TOKEN') || (typeof entry.token === 'string' ? entry.token : '') } else if (platform === 'discord') { - form.token = typeof entry.token === 'string' ? entry.token : '' + form.token = hermesEnvValue(envValues, 'DISCORD_BOT_TOKEN') || (typeof entry.token === 'string' ? entry.token : '') } else if (platform === 'slack') { - form.botToken = typeof entry.token === 'string' ? entry.token : '' + form.botToken = hermesEnvValue(envValues, 'SLACK_BOT_TOKEN') || (typeof entry.token === 'string' ? entry.token : '') putHermesString(form, extra, 'app_token') + form.appToken = hermesEnvValue(envValues, 'SLACK_APP_TOKEN') || form.appToken || '' putHermesString(form, extra, 'signing_secret') putHermesString(form, extra, 'webhook_path') } else if (platform === 'feishu') { for (const key of ['app_id', 'app_secret', 'domain', 'connection_mode', 'webhook_path', 'reaction_notifications']) { putHermesString(form, extra, key) } + form.appId = hermesEnvValue(envValues, 'FEISHU_APP_ID') || form.appId || '' + form.appSecret = hermesEnvValue(envValues, 'FEISHU_APP_SECRET') || form.appSecret || '' + form.domain = hermesEnvValue(envValues, 'FEISHU_DOMAIN') || form.domain || '' + form.connectionMode = hermesEnvValue(envValues, 'FEISHU_CONNECTION_MODE') || form.connectionMode || '' + form.webhookPath = hermesEnvValue(envValues, 'FEISHU_WEBHOOK_PATH') || form.webhookPath || '' for (const key of ['typing_indicator', 'resolve_sender_names']) { putHermesBool(form, extra, key) } @@ -2770,6 +2792,14 @@ function setHermesExtra(entry, key, value) { entry.extra[key] = value } +function deleteHermesEntryKey(entry, key) { + if (entry && typeof entry === 'object') delete entry[key] +} + +function deleteHermesExtraKey(entry, key) { + if (entry?.extra && typeof entry.extra === 'object' && !Array.isArray(entry.extra)) delete entry.extra[key] +} + function normalizeHermesChannelForm(platform, form = {}) { const normalized = { ...(form || {}) } normalized.enabled = normalized.enabled === true || normalized.enabled === 'true' || normalized.enabled === 'on' @@ -2806,17 +2836,17 @@ export function mergeHermesChannelConfig(config = {}, platform, form = {}) { const normalized = normalizeHermesChannelForm(normalizedPlatform, form) entry.enabled = normalized.enabled if (normalizedPlatform === 'telegram') { - if (typeof normalized.botToken === 'string') entry.token = normalized.botToken.trim() + deleteHermesEntryKey(entry, 'token') } else if (normalizedPlatform === 'discord') { - if (typeof normalized.token === 'string') entry.token = normalized.token.trim() + deleteHermesEntryKey(entry, 'token') } else if (normalizedPlatform === 'slack') { - if (typeof normalized.botToken === 'string') entry.token = normalized.botToken.trim() - setHermesExtra(entry, 'app_token', String(normalized.appToken || '').trim()) - setHermesExtra(entry, 'signing_secret', String(normalized.signingSecret || '').trim()) + deleteHermesEntryKey(entry, 'token') + deleteHermesExtraKey(entry, 'app_token') + deleteHermesExtraKey(entry, 'signing_secret') setHermesExtra(entry, 'webhook_path', String(normalized.webhookPath || '').trim()) } else if (normalizedPlatform === 'feishu') { - setHermesExtra(entry, 'app_id', String(normalized.appId || '').trim()) - setHermesExtra(entry, 'app_secret', String(normalized.appSecret || '').trim()) + deleteHermesExtraKey(entry, 'app_id') + deleteHermesExtraKey(entry, 'app_secret') setHermesExtra(entry, 'domain', normalized.domain) setHermesExtra(entry, 'connection_mode', normalized.connectionMode) setHermesExtra(entry, 'webhook_path', normalized.webhookPath) @@ -8055,10 +8085,11 @@ const handlers = { hermes_channel_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() + const envValues = readHermesEnvValues() return { exists, configPath, - values: buildHermesChannelConfigValues(config), + values: buildHermesChannelConfigValues(config, envValues), } }, @@ -8069,10 +8100,11 @@ const handlers = { const next = mergeHermesChannelConfig(config, normalizedPlatform, form || {}) writeHermesConfigYamlObject(configPath, next) writeHermesEnvValues(buildHermesChannelEnvUpdates(normalizedPlatform, form || {})) + const envValues = { ...readHermesEnvValues(), ...buildHermesChannelEnvUpdates(normalizedPlatform, form || {}) } return { ok: true, configPath, - values: buildHermesChannelConfigValues(next)[normalizedPlatform], + values: buildHermesChannelConfigValues(next, envValues)[normalizedPlatform], } }, diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index bf382e3..b841d14 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -2242,7 +2242,47 @@ fn insert_json_csv_if_present( } } -fn build_hermes_channel_config_values(config: &serde_yaml::Value) -> Value { +fn hermes_env_value( + env_values: &std::collections::HashMap, + key: &str, +) -> Option { + env_values + .get(key) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn read_hermes_channel_env_values() -> std::collections::HashMap { + let env_path = hermes_home().join(".env"); + let raw = std::fs::read_to_string(&env_path).unwrap_or_default(); + let mut values = std::collections::HashMap::new(); + for (key, value, _) in parse_env_file_lines(&raw) { + values.entry(key).or_insert(value); + } + values +} + +fn json_form_string(form: &serde_json::Map, key: &str) -> Option { + form.get(key) + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) +} + +fn put_json_string_from_env( + form: &mut serde_json::Map, + env_values: &std::collections::HashMap, + env_key: &str, + json_key: &str, +) { + if let Some(value) = hermes_env_value(env_values, env_key) { + form.insert(json_key.to_string(), Value::String(value)); + } +} + +fn build_hermes_channel_config_values( + config: &serde_yaml::Value, + env_values: &std::collections::HashMap, +) -> Value { let mut values = serde_json::Map::new(); let root = config.as_mapping(); let platforms = root.and_then(|map| yaml_get_mapping(map, "platforms")); @@ -2263,23 +2303,27 @@ fn build_hermes_channel_config_values(config: &serde_yaml::Value) -> Value { match platform { "telegram" => { - form.insert( - "botToken".to_string(), - Value::String(yaml_string_field(&entry, "token").unwrap_or_default()), - ); + let token = hermes_env_value(env_values, "TELEGRAM_BOT_TOKEN") + .or_else(|| yaml_string_field(&entry, "token")) + .unwrap_or_default(); + form.insert("botToken".to_string(), Value::String(token)); } "discord" => { - form.insert( - "token".to_string(), - Value::String(yaml_string_field(&entry, "token").unwrap_or_default()), - ); + let token = hermes_env_value(env_values, "DISCORD_BOT_TOKEN") + .or_else(|| yaml_string_field(&entry, "token")) + .unwrap_or_default(); + form.insert("token".to_string(), Value::String(token)); } "slack" => { - form.insert( - "botToken".to_string(), - Value::String(yaml_string_field(&entry, "token").unwrap_or_default()), - ); + let bot_token = hermes_env_value(env_values, "SLACK_BOT_TOKEN") + .or_else(|| yaml_string_field(&entry, "token")) + .unwrap_or_default(); + form.insert("botToken".to_string(), Value::String(bot_token)); insert_json_string_if_present(&mut form, &extra, "app_token", "appToken"); + let app_token = hermes_env_value(env_values, "SLACK_APP_TOKEN") + .or_else(|| json_form_string(&form, "appToken")) + .unwrap_or_default(); + form.insert("appToken".to_string(), Value::String(app_token)); insert_json_string_if_present(&mut form, &extra, "signing_secret", "signingSecret"); insert_json_string_if_present(&mut form, &extra, "webhook_path", "webhookPath"); } @@ -2300,6 +2344,21 @@ fn build_hermes_channel_config_values(config: &serde_yaml::Value) -> Value { "reaction_notifications", "reactionNotifications", ); + put_json_string_from_env(&mut form, env_values, "FEISHU_APP_ID", "appId"); + put_json_string_from_env(&mut form, env_values, "FEISHU_APP_SECRET", "appSecret"); + put_json_string_from_env(&mut form, env_values, "FEISHU_DOMAIN", "domain"); + put_json_string_from_env( + &mut form, + env_values, + "FEISHU_CONNECTION_MODE", + "connectionMode", + ); + put_json_string_from_env( + &mut form, + env_values, + "FEISHU_WEBHOOK_PATH", + "webhookPath", + ); insert_json_bool_if_present( &mut form, &extra, @@ -2357,15 +2416,6 @@ fn yaml_child_object<'a>( .ok_or_else(|| format!("{key} 必须是对象")) } -fn set_yaml_string_if_present(entry: &mut serde_yaml::Mapping, key: &str, value: Option) { - if let Some(value) = value { - entry.insert( - yaml_key(key), - serde_yaml::Value::String(value.trim().to_string()), - ); - } -} - fn set_extra_string_if_present(entry: &mut serde_yaml::Mapping, key: &str, value: Option) { if let Some(value) = value .map(|v| v.trim().to_string()) @@ -2377,6 +2427,19 @@ fn set_extra_string_if_present(entry: &mut serde_yaml::Mapping, key: &str, value } } +fn delete_yaml_key(entry: &mut serde_yaml::Mapping, key: &str) { + entry.remove(yaml_key(key)); +} + +fn delete_extra_key(entry: &mut serde_yaml::Mapping, key: &str) { + if let Some(extra) = entry + .get_mut(yaml_key("extra")) + .and_then(|value| value.as_mapping_mut()) + { + extra.remove(yaml_key(key)); + } +} + fn set_extra_bool(entry: &mut serde_yaml::Mapping, key: &str, value: bool) { if let Ok(extra) = yaml_child_object(entry, "extra") { extra.insert(yaml_key(key), serde_yaml::Value::Bool(value)); @@ -2488,16 +2551,12 @@ fn merge_hermes_channel_config( ); match platform { - "telegram" => set_yaml_string_if_present(entry, "token", form_string(form, "botToken")), - "discord" => set_yaml_string_if_present(entry, "token", form_string(form, "token")), + "telegram" => delete_yaml_key(entry, "token"), + "discord" => delete_yaml_key(entry, "token"), "slack" => { - set_yaml_string_if_present(entry, "token", form_string(form, "botToken")); - set_extra_string_if_present(entry, "app_token", form_string(form, "appToken")); - set_extra_string_if_present( - entry, - "signing_secret", - form_string(form, "signingSecret"), - ); + delete_yaml_key(entry, "token"); + delete_extra_key(entry, "app_token"); + delete_extra_key(entry, "signing_secret"); set_extra_string_if_present( entry, "webhook_path", @@ -2505,8 +2564,8 @@ fn merge_hermes_channel_config( ); } "feishu" => { - set_extra_string_if_present(entry, "app_id", form_string(form, "appId")); - set_extra_string_if_present(entry, "app_secret", form_string(form, "appSecret")); + delete_extra_key(entry, "app_id"); + delete_extra_key(entry, "app_secret"); set_extra_string_if_present( entry, "domain", @@ -2757,10 +2816,11 @@ fn write_hermes_channel_env(platform: &str, form: &Value) -> Result<(), String> pub fn hermes_channel_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; ensure_yaml_object(&mut config.clone())?; + let env_values = read_hermes_channel_env_values(); Ok(serde_json::json!({ "exists": exists, "configPath": config_path.to_string_lossy(), - "values": build_hermes_channel_config_values(&config), + "values": build_hermes_channel_config_values(&config, &env_values), })) } @@ -2772,7 +2832,11 @@ pub fn hermes_channel_config_save(platform: String, form: Value) -> Result { _stateListeners = _stateListeners.filter(cb => cb !== fn) } + _listeners.push(fn) + return () => { _listeners = _listeners.filter(cb => cb !== fn) } }, onReadyChange(fn) { - _readyListeners.push(fn) - return () => { _readyListeners = _readyListeners.filter(cb => cb !== fn) } + _listeners.push(fn) + return () => { _listeners = _listeners.filter(cb => cb !== fn) } }, isFeatureAvailable() { return true }, diff --git a/src/engines/hermes/pages/channels.js b/src/engines/hermes/pages/channels.js index cb7bce0..973e660 100644 --- a/src/engines/hermes/pages/channels.js +++ b/src/engines/hermes/pages/channels.js @@ -193,7 +193,7 @@ export function render() {
${esc(t('engine.hermesChannelEnabledCount'))}${enabledCount}
${esc(t('engine.hermesChannelConfiguredCount'))}${configuredCount}
-
${esc(t('engine.hermesChannelRuntimeWrite'))}YAML + .env
+
${esc(t('engine.hermesChannelRuntimeWrite'))}${esc(t('engine.hermesChannelRuntimeWriteValue'))}
${(error || success) ? ` diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 061db9c..91d7f3e 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -916,6 +916,7 @@ export default { hermesChannelEnabledCount: _('已启用', 'Enabled', '已啟用'), hermesChannelConfiguredCount: _('已填写凭证', 'Credentials set', '已填寫憑證'), hermesChannelRuntimeWrite: _('写入位置', 'Writes to', '寫入位置'), + hermesChannelRuntimeWriteValue: _('config.yaml + .env', 'config.yaml + .env', 'config.yaml + .env'), hermesChannelPlatforms: _('渠道', 'Platforms', '頻道'), hermesChannelTelegram: _('Telegram', 'Telegram', 'Telegram'), hermesChannelDiscord: _('Discord', 'Discord', 'Discord'), @@ -961,7 +962,7 @@ export default { hermesChannelAllowFromPlaceholder: _('每行或逗号分隔一个用户 ID,开放策略可留空。', 'One user ID per line or comma-separated. Leave empty for open policy.', '每行或逗號分隔一個使用者 ID,開放策略可留空。'), hermesChannelGroupAllowFromPlaceholder: _('每行或逗号分隔一个群组 / 频道 ID。', 'One group or channel ID per line or comma-separated.', '每行或逗號分隔一個群組 / 頻道 ID。'), hermesChannelRequireMention: _('群组消息需要 @Bot 才响应', 'Require @mention in groups', '群組訊息需要 @Bot 才回應'), - hermesChannelRestartHint: _('保存会同时写入 config.yaml 和 .env。Hermes Gateway 读取启动时配置,修改后请重启 Gateway。', 'Saving writes both config.yaml and .env. Hermes Gateway reads them on startup, so restart the gateway after changes.', '儲存會同時寫入 config.yaml 和 .env。Hermes Gateway 於啟動時讀取設定,修改後請重啟 Gateway。'), + hermesChannelRestartHint: _('保存会将访问策略等偏好写入 config.yaml,并将 Bot Token、App Secret 及 Hermes 运行时兼容环境变量同步到 .env。Hermes Gateway 读取启动时配置,修改后请重启 Gateway。', 'Saving writes access preferences to config.yaml and syncs Bot Token, App Secret, and Hermes runtime compatibility variables to .env. Hermes Gateway reads them on startup, so restart the gateway after changes.', '儲存會將存取策略等偏好寫入 config.yaml,並將 Bot Token、App Secret 及 Hermes 執行時相容環境變數同步到 .env。Hermes Gateway 於啟動時讀取設定,修改後請重啟 Gateway。'), extensionsEyebrow: _('HERMES AGENT · 扩展', 'HERMES AGENT · EXTENSIONS', 'HERMES AGENT · 擴展'), extensionsTitle: _('文档 / 插件 / 主题', 'Docs / Plugins / Themes', '文件 / 插件 / 主題'), extensionsDesc: _('集中管理 Dashboard 扩展清单、视觉主题和使用洞察。', 'Manage dashboard extension manifests, visual themes and usage intelligence.', '集中管理 Dashboard 擴展清單、視覺主題和使用洞察。'), diff --git a/tests/hermes-channel-config.test.js b/tests/hermes-channel-config.test.js index 0926ce9..d779703 100644 --- a/tests/hermes-channel-config.test.js +++ b/tests/hermes-channel-config.test.js @@ -31,6 +31,42 @@ test('Hermes 渠道读取会从 platforms 平台配置生成稳定表单值', () assert.equal(values.telegram.requireMention, true) }) +test('Hermes 渠道读取会按运行时优先级合并 .env 凭证', () => { + const values = buildHermesChannelConfigValues({ + platforms: { + telegram: { + enabled: true, + token: 'yaml-token', + extra: { + allow_from: ['1001'], + }, + }, + feishu: { + enabled: true, + extra: { + app_id: 'yaml-app-id', + app_secret: 'yaml-secret', + domain: 'lark', + connection_mode: 'webhook', + }, + }, + }, + }, { + TELEGRAM_BOT_TOKEN: 'env-token', + FEISHU_APP_ID: 'env-app-id', + FEISHU_APP_SECRET: 'env-secret', + FEISHU_DOMAIN: 'feishu', + FEISHU_CONNECTION_MODE: 'websocket', + }) + + assert.equal(values.telegram.botToken, 'env-token') + assert.equal(values.telegram.allowFrom, '1001') + assert.equal(values.feishu.appId, 'env-app-id') + assert.equal(values.feishu.appSecret, 'env-secret') + assert.equal(values.feishu.domain, 'feishu') + assert.equal(values.feishu.connectionMode, 'websocket') +}) + test('Hermes 渠道保存会写入 Hermes 最新 platforms 配置并保留无关配置', () => { const next = mergeHermesChannelConfig({ model: { provider: 'anthropic', default: 'claude-sonnet-4-6' }, @@ -54,7 +90,7 @@ test('Hermes 渠道保存会写入 Hermes 最新 platforms 配置并保留无关 assert.deepEqual(next.model, { provider: 'anthropic', default: 'claude-sonnet-4-6' }) assert.equal(next.platforms.telegram.enabled, true) - assert.equal(next.platforms.telegram.token, '123:token') + assert.equal(next.platforms.telegram.token, undefined) assert.equal(next.platforms.telegram.extra.dm_policy, 'pair') assert.equal(next.platforms.telegram.extra.group_policy, 'allowlist') assert.deepEqual(next.platforms.telegram.extra.allow_from, ['1001', '1002']) @@ -76,8 +112,8 @@ test('Hermes 飞书保存会补齐可运行默认项并使用 Hermes snake_case }) assert.equal(next.platforms.feishu.enabled, true) - assert.equal(next.platforms.feishu.extra.app_id, 'cli_xxx') - assert.equal(next.platforms.feishu.extra.app_secret, 'secret') + assert.equal(next.platforms.feishu.extra.app_id, undefined) + assert.equal(next.platforms.feishu.extra.app_secret, undefined) assert.equal(next.platforms.feishu.extra.domain, 'feishu') assert.equal(next.platforms.feishu.extra.connection_mode, 'websocket') assert.equal(next.platforms.feishu.extra.webhook_path, '/feishu/webhook') @@ -117,3 +153,32 @@ test('Hermes 渠道保存会生成运行时仍会读取的环境变量', () => { assert.equal(feishuEnv.FEISHU_GROUP_POLICY, 'allowlist') assert.equal(feishuEnv.FEISHU_REACTIONS, 'false') }) + +test('Hermes 渠道保存会从 YAML 清理旧凭证,避免覆盖 .env 运行时值', () => { + const next = mergeHermesChannelConfig({ + platforms: { + slack: { + enabled: true, + token: 'old-bot-token', + extra: { + app_token: 'old-app-token', + signing_secret: 'old-signing-secret', + webhook_path: '/old/events', + unknown_option: 'keep-me', + }, + }, + }, + }, 'slack', { + enabled: true, + botToken: 'xoxb-new', + appToken: 'xapp-new', + signingSecret: 'new-signing-secret', + webhookPath: '/slack/events', + }) + + assert.equal(next.platforms.slack.token, undefined) + assert.equal(next.platforms.slack.extra.app_token, undefined) + assert.equal(next.platforms.slack.extra.signing_secret, undefined) + assert.equal(next.platforms.slack.extra.webhook_path, '/slack/events') + assert.equal(next.platforms.slack.extra.unknown_option, 'keep-me') +}) diff --git a/tests/hermes-engine-listeners.test.js b/tests/hermes-engine-listeners.test.js new file mode 100644 index 0000000..01765c7 --- /dev/null +++ b/tests/hermes-engine-listeners.test.js @@ -0,0 +1,26 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +globalThis.window = globalThis.window || { location: { hostname: '127.0.0.1' } } +globalThis.localStorage = globalThis.localStorage || { + getItem() { return null }, + setItem() {}, +} + +test('Hermes 引擎状态监听注册和取消不会引用不存在的监听数组', async () => { + const { default: hermesEngine } = await import('../src/engines/hermes/index.js') + + let stateUnsub + let readyUnsub + assert.doesNotThrow(() => { + stateUnsub = hermesEngine.onStateChange(() => {}) + readyUnsub = hermesEngine.onReadyChange(() => {}) + }) + + assert.equal(typeof stateUnsub, 'function') + assert.equal(typeof readyUnsub, 'function') + assert.doesNotThrow(() => { + stateUnsub() + readyUnsub() + }) +})