Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions scripts/dev-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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']) {
Expand Down Expand Up @@ -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') {
Expand Down
43 changes: 43 additions & 0 deletions src-tauri/src/commands/hermes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" => {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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(
Expand Down
66 changes: 66 additions & 0 deletions tests/dev-api-models-sync.test.js
Original file line number Diff line number Diff line change
@@ -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 })
}
})
37 changes: 37 additions & 0 deletions tests/hermes-channel-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading