fix: persist Slack signing secret and sync models.json in web mode

Two critical correctness bugs from recent commits:

1. Hermes Slack signingSecret was stripped from config.yaml on save but
   never written to SLACK_SIGNING_SECRET in ~/.hermes/.env, causing data
   loss and broken HTTP webhook mode after any channel save.

2. Tauri write_openclaw_config syncs providers to agents/*/models.json
   (Issue #127 fix) but the dev-api web shim did not, leaving Gateway
   with stale models.json after Models page saves in browser mode.

Add regression tests for both paths.

Co-authored-by: 晴天 <1186258278@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-05-29 11:10:22 +00:00
parent 38934fe754
commit 2debbc5f2e
4 changed files with 208 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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