diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 40200839..aefa88a4 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2247,10 +2247,70 @@ function mergeConfigsPreservingFields(existing, next) { return merged } +export function syncProvidersToAgentModels(config, openclawDir = OPENCLAW_DIR) { + const srcProviders = config?.models?.providers + if (!srcProviders || typeof srcProviders !== 'object' || Array.isArray(srcProviders)) return + + const agentIds = ['main'] + for (const agent of Array.isArray(config?.agents?.list) ? config.agents.list : []) { + const id = String(agent?.id || '').trim() + if (id && id !== 'main') agentIds.push(id) + } + + const agentsDir = path.join(openclawDir, 'agents') + for (const agentId of agentIds) { + const modelsPath = path.join(agentsDir, agentId, 'agent', 'models.json') + if (!fs.existsSync(modelsPath)) continue + + let modelsJson + try { + modelsJson = JSON.parse(fs.readFileSync(modelsPath, 'utf8')) + } catch { + continue + } + if (!modelsJson || typeof modelsJson !== 'object' || Array.isArray(modelsJson)) continue + + let changed = false + if (!modelsJson.providers || typeof modelsJson.providers !== 'object' || Array.isArray(modelsJson.providers)) { + modelsJson.providers = {} + changed = true + } + + const dstProviders = modelsJson.providers + for (const providerName of Object.keys(dstProviders)) { + if (!Object.hasOwn(srcProviders, providerName)) { + delete dstProviders[providerName] + changed = true + } + } + for (const [providerName, srcProvider] of Object.entries(srcProviders)) { + if (!Object.hasOwn(dstProviders, providerName)) { + dstProviders[providerName] = srcProvider + changed = true + continue + } + const dstProvider = dstProviders[providerName] + if (!dstProvider || typeof dstProvider !== 'object' || Array.isArray(dstProvider)) continue + for (const field of ['baseUrl', 'apiKey', 'api']) { + const srcVal = srcProvider?.[field] + if (typeof srcVal === 'string' && dstProvider[field] !== srcVal) { + dstProvider[field] = srcVal + changed = true + } + } + } + + if (changed) { + fs.writeFileSync(modelsPath, JSON.stringify(modelsJson, null, 2)) + } + } +} + function writeOpenclawConfigFile(config) { const cleaned = stripUiFields(config) if (fs.existsSync(CONFIG_PATH)) fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + '.bak') fs.writeFileSync(CONFIG_PATH, JSON.stringify(cleaned, null, 2)) + syncProvidersToAgentModels(cleaned) } function ensureAgentsList(config) { @@ -6313,6 +6373,7 @@ export function buildHermesChannelConfigValues(config = {}, envValues = {}) { putHermesString(form, extra, 'app_token') form.appToken = hermesEnvValue(envValues, 'SLACK_APP_TOKEN') || form.appToken || '' putHermesString(form, extra, 'signing_secret') + form.signingSecret = hermesEnvValue(envValues, 'SLACK_SIGNING_SECRET') || form.signingSecret || '' putHermesString(form, extra, 'webhook_path') } else if (platform === 'feishu') { for (const key of ['app_id', 'app_secret', 'domain', 'connection_mode', 'webhook_path', 'reaction_notifications']) { @@ -6772,6 +6833,7 @@ export function buildHermesChannelEnvUpdates(platform, form = {}) { } else if (platform === 'slack') { updates.SLACK_BOT_TOKEN = String(form.botToken || '').trim() updates.SLACK_APP_TOKEN = String(form.appToken || '').trim() + updates.SLACK_SIGNING_SECRET = String(form.signingSecret || '').trim() updates.SLACK_ALLOWED_USERS = csvEnvValue(form.allowFrom) if (Object.hasOwn(form, 'requireMention')) updates.SLACK_REQUIRE_MENTION = boolEnvValue(form.requireMention) } else if (platform === 'feishu') { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 8d5da331..7a6f2ab8 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3402,6 +3402,10 @@ fn build_hermes_channel_config_values( .unwrap_or_default(); form.insert("appToken".to_string(), Value::String(app_token)); insert_json_string_if_present(&mut form, &extra, "signing_secret", "signingSecret"); + let signing_secret = hermes_env_value(env_values, "SLACK_SIGNING_SECRET") + .or_else(|| json_form_string(&form, "signingSecret")) + .unwrap_or_default(); + form.insert("signingSecret".to_string(), Value::String(signing_secret)); insert_json_string_if_present(&mut form, &extra, "webhook_path", "webhookPath"); } "feishu" => { @@ -11182,6 +11186,10 @@ fn build_hermes_channel_env_updates(platform: &str, form: &Value) -> Vec<(String "SLACK_APP_TOKEN", form_string(form, "appToken").unwrap_or_default(), ); + push( + "SLACK_SIGNING_SECRET", + form_string(form, "signingSecret").unwrap_or_default(), + ); push("SLACK_ALLOWED_USERS", csv_env_value(form, "allowFrom")); if let Some(value) = form_bool(form, "requireMention") { push("SLACK_REQUIRE_MENTION", bool_env_value(value)); @@ -24517,6 +24525,41 @@ platforms: ); } + #[test] + fn channel_env_updates_include_slack_signing_secret() { + let env = build_hermes_channel_env_updates( + "slack", + &json!({ + "botToken": "xoxb-new", + "appToken": "xapp-new", + "signingSecret": "new-signing-secret", + "allowFrom": ["U1"], + "requireMention": true, + }), + ); + + assert!(env.contains(&( + "SLACK_BOT_TOKEN".to_string(), + "xoxb-new".to_string() + ))); + assert!(env.contains(&( + "SLACK_APP_TOKEN".to_string(), + "xapp-new".to_string() + ))); + assert!(env.contains(&( + "SLACK_SIGNING_SECRET".to_string(), + "new-signing-secret".to_string() + ))); + assert!(env.contains(&( + "SLACK_ALLOWED_USERS".to_string(), + "U1".to_string() + ))); + assert!(env.contains(&( + "SLACK_REQUIRE_MENTION".to_string(), + "true".to_string() + ))); + } + #[test] fn plugin_platform_values_prefer_env_and_preserve_yaml_runtime_fields() { let config: serde_yaml::Value = serde_yaml::from_str( diff --git a/tests/dev-api-models-sync.test.js b/tests/dev-api-models-sync.test.js new file mode 100644 index 00000000..79ae41d0 --- /dev/null +++ b/tests/dev-api-models-sync.test.js @@ -0,0 +1,66 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { syncProvidersToAgentModels } from '../scripts/dev-api.js' + +test('Web API write 会同步 openclaw.json providers 到 agent models.json', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-models-sync-')) + try { + const modelsPath = path.join(tmp, 'agents', 'main', 'agent', 'models.json') + fs.mkdirSync(path.dirname(modelsPath), { recursive: true }) + fs.writeFileSync(modelsPath, JSON.stringify({ + providers: { + a: { baseUrl: 'http://old-a', models: [{ id: 'm1' }] }, + b: { baseUrl: 'http://old-b', models: [{ id: 'm2' }] }, + }, + }, null, 2)) + + syncProvidersToAgentModels({ + models: { + providers: { + a: { baseUrl: 'http://new-a', apiKey: 'key-a', models: [{ id: 'm1' }] }, + }, + }, + }, tmp) + + const synced = JSON.parse(fs.readFileSync(modelsPath, 'utf8')) + assert.equal(synced.providers.a.baseUrl, 'http://new-a') + assert.equal(synced.providers.a.apiKey, 'key-a') + assert.equal(synced.providers.b, undefined) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('Web API provider sync 保留 agent models.json 中用户手动添加的 provider 模型列表', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-models-sync-')) + try { + const modelsPath = path.join(tmp, 'agents', 'main', 'agent', 'models.json') + fs.mkdirSync(path.dirname(modelsPath), { recursive: true }) + fs.writeFileSync(modelsPath, JSON.stringify({ + providers: { + a: { + baseUrl: 'http://old-a', + models: [{ id: 'm1' }, { id: 'custom-model' }], + }, + }, + }, null, 2)) + + syncProvidersToAgentModels({ + models: { + providers: { + a: { baseUrl: 'http://new-a', models: [{ id: 'm1' }] }, + }, + }, + }, tmp) + + const synced = JSON.parse(fs.readFileSync(modelsPath, 'utf8')) + assert.equal(synced.providers.a.baseUrl, 'http://new-a') + assert.deepEqual(synced.providers.a.models, [{ id: 'm1' }, { id: 'custom-model' }]) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) diff --git a/tests/hermes-channel-config.test.js b/tests/hermes-channel-config.test.js index 7ff54190..f6e012e6 100644 --- a/tests/hermes-channel-config.test.js +++ b/tests/hermes-channel-config.test.js @@ -360,6 +360,43 @@ test('Hermes 渠道保存会从 YAML 清理旧凭证,避免覆盖 .env 运行 assert.equal(next.platforms.slack.extra.unknown_option, 'keep-me') }) +test('Hermes Slack 保存会将 signingSecret 写入 SLACK_SIGNING_SECRET 环境变量', () => { + const env = buildHermesChannelEnvUpdates('slack', { + botToken: 'xoxb-new', + appToken: 'xapp-new', + signingSecret: 'new-signing-secret', + allowFrom: ['U1'], + requireMention: true, + }) + + assert.equal(env.SLACK_BOT_TOKEN, 'xoxb-new') + assert.equal(env.SLACK_APP_TOKEN, 'xapp-new') + assert.equal(env.SLACK_SIGNING_SECRET, 'new-signing-secret') + assert.equal(env.SLACK_ALLOWED_USERS, 'U1') + assert.equal(env.SLACK_REQUIRE_MENTION, 'true') +}) + +test('Hermes Slack 读取会从 SLACK_SIGNING_SECRET 环境变量回填 signingSecret', () => { + const values = buildHermesChannelConfigValues({ + platforms: { + slack: { + enabled: true, + extra: { + webhook_path: '/slack/events', + }, + }, + }, + }, { + SLACK_BOT_TOKEN: 'xoxb-env', + SLACK_APP_TOKEN: 'xapp-env', + SLACK_SIGNING_SECRET: 'signing-from-env', + }) + + assert.equal(values.slack.botToken, 'xoxb-env') + assert.equal(values.slack.appToken, 'xapp-env') + assert.equal(values.slack.signingSecret, 'signing-from-env') +}) + test('Hermes 钉钉保存会使用运行时实际读取的字段', () => { const next = mergeHermesChannelConfig({ platforms: {