修复: Profile clone 时智能清理独占平台凭据 + 平台设置独占警告 (#283)

* 修复: profile clone 时智能清理独占平台凭据,避免 gateway 健康检查超时

# 问题
`hermes profile create <name> --clone` 完整复制 .env + config.yaml(含独占型平台凭据
如 WEIXIN_TOKEN / TELEGRAM_BOT_TOKEN 等),导致多个 profile 共享同一身份 token。
hermes-agent 在 platform adapter 初始化或 scoped lock 获取阶段失败,gateway 健康检查
持续 15s 超时,前端报 'API Error 500: Gateway health check timed out'。

# 修复
在 web-ui 后端 clone 完成后自动:
1. 从 <profile>/.env 删除匹配独占平台的环境变量(写 .env.bak.* 备份)
2. 在 <profile>/config.yaml 中把 platforms.<exclusive>.enabled 置为 false
3. 清理节点直挂 + extra 子节点下的敏感字段(token / app_secret / account_id 等)

前端 toast 提示被剥离的凭据、被禁用的平台、被剥离的 config 字段,便于用户后续手动
重新填入新身份再启用。

# EXCLUSIVE_PLATFORMS 列表来源
精确对齐 hermes-agent gateway/platforms/*.py 中调用 _acquire_platform_lock 的 7 个
adapter: telegram, discord, slack, whatsapp, signal, weixin, feishu。
未来上游加新独占平台时用 `grep -l _acquire_platform_lock gateway/platforms/*.py` 验证。

# 测试
新增 tests/server/profile-credentials.test.ts(12 用例全过),覆盖:
- isExclusivePlatformKey 命中/未命中边界
- env 文件剥离 + 备份
- config.yaml 平台禁用 + 节点凭据清理
- 已 disabled 平台仍清理残留凭据(防止后续 re-enable 复用旧身份)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(平台设置): 独占平台显示 token 隔离警告

在 PlatformSettings 中为使用 token 互斥锁的 6 个平台 (telegram, discord, slack,
whatsapp, feishu, weixin) 添加视觉警告,提示用户每个 profile 必须使用不同的身份
token,避免与其他 profile 冲突。

# 背景
hermes-agent 的 acquire_scoped_lock 是 token-level(不是 platform-level),所以
设计上支持多 profile 各自配不同身份的同一平台(如 default 用个人微信、staging
用公司微信)。但用户从 UI 配置时容易误填同一 token,导致 gateway 启动失败。

# 实现
- PlatformCard 新增 exclusive 可选 prop,开启时 body 顶部用 NAlert (warning)
  展示提示
- PlatformSettings 在 6 个独占平台数组项标记 exclusive: true 并传给 PlatformCard
- 8 个 i18n locale 新增 platform.exclusiveTokenWarning 翻译

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
ww
2026-04-29 20:31:24 +08:00
committed by GitHub
parent 6511033ed8
commit 2ae7e7ad1b
16 changed files with 543 additions and 13 deletions
+205
View File
@@ -0,0 +1,205 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
isExclusivePlatformKey,
stripExclusivePlatformCredentials,
disableExclusivePlatformsInConfig,
EXCLUSIVE_PLATFORMS,
EXCLUSIVE_PLATFORM_ENV_PATTERNS,
} from '../../packages/server/src/services/hermes/profile-credentials'
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'profile-cred-test-'))
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
describe('isExclusivePlatformKey', () => {
it('matches all known exclusive platform prefixes (aligned with hermes-agent gateway/platforms)', () => {
const samples = [
'TELEGRAM_BOT_TOKEN',
'DISCORD_BOT_TOKEN',
'SLACK_APP_TOKEN',
'WHATSAPP_PHONE_NUMBER_ID',
'SIGNAL_PHONE_NUMBER',
'WEIXIN_TOKEN', 'WEIXIN_ACCOUNT_ID',
'FEISHU_APP_ID',
]
for (const k of samples) {
expect(isExclusivePlatformKey(k)).toBe(true)
}
})
it('does not match removed aliases or non-lock platforms', () => {
// 这些前缀在 hermes-agent gateway/platforms/ 中没有 _acquire_platform_lock 调用
const nonLock = [
'WECHAT_APP_ID', // wechat 不是上游 platform key(实际是 weixin
'LARK_APP_SECRET', // lark 不是上游 platform key(实际是 feishu
'LINE_CHANNEL_SECRET', // line 在 hermes-agent 中没有 adapter
'MATTERMOST_TOKEN', 'MATRIX_TOKEN', 'DINGTALK_TOKEN',
'WECOM_TOKEN', 'QQBOT_TOKEN', 'BLUEBUBBLES_TOKEN',
]
for (const k of nonLock) {
expect(isExclusivePlatformKey(k)).toBe(false)
}
})
it('does not match model provider keys or generic config', () => {
const safe = [
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GEMINI_API_KEY',
'DEEPSEEK_API_KEY',
'MINIMAX_API_KEY',
'DASHSCOPE_API_KEY',
'BROWSER_HEADLESS',
'TERMINAL_DEFAULT_SHELL',
'HERMES_MAX_ITERATIONS',
'PORT',
'NODE_ENV',
]
for (const k of safe) {
expect(isExclusivePlatformKey(k)).toBe(false)
}
})
})
describe('stripExclusivePlatformCredentials', () => {
it('returns empty when file does not exist', () => {
expect(stripExclusivePlatformCredentials(join(tmpDir, 'nope.env'))).toEqual([])
})
it('returns empty and does not write when no exclusive keys present', () => {
const p = join(tmpDir, '.env')
const content = 'OPENAI_API_KEY=sk-xxx\nPORT=8642\n'
writeFileSync(p, content)
expect(stripExclusivePlatformCredentials(p)).toEqual([])
expect(readFileSync(p, 'utf-8')).toBe(content)
// 无备份文件
expect(readdirSync(tmpDir).filter(f => f.startsWith('.env.bak'))).toHaveLength(0)
})
it('strips exclusive credentials, keeps safe ones, and creates a backup', () => {
const p = join(tmpDir, '.env')
writeFileSync(p, [
'# comment',
'OPENAI_API_KEY=sk-xxx',
'WEIXIN_TOKEN=secret-token',
'WEIXIN_ACCOUNT_ID=acct-1',
'TELEGRAM_BOT_TOKEN=tg-token',
'PORT=8642',
'',
].join('\n'))
const removed = stripExclusivePlatformCredentials(p)
expect(removed).toEqual(['WEIXIN_TOKEN', 'WEIXIN_ACCOUNT_ID', 'TELEGRAM_BOT_TOKEN'])
const after = readFileSync(p, 'utf-8')
expect(after).toContain('OPENAI_API_KEY=sk-xxx')
expect(after).toContain('PORT=8642')
expect(after).toContain('# comment')
expect(after).not.toContain('WEIXIN_')
expect(after).not.toContain('TELEGRAM_')
// 备份文件存在且与原始内容一致
const backups = readdirSync(tmpDir).filter(f => f.startsWith('.env.bak'))
expect(backups).toHaveLength(1)
const backupContent = readFileSync(join(tmpDir, backups[0]), 'utf-8')
expect(backupContent).toContain('WEIXIN_TOKEN=secret-token')
})
})
describe('disableExclusivePlatformsInConfig', () => {
it('returns empty when file does not exist', () => {
expect(disableExclusivePlatformsInConfig(join(tmpDir, 'nope.yaml')))
.toEqual({ disabled: [], strippedConfigCredentials: [] })
})
it('returns empty when no exclusive platforms enabled and no embedded credentials', () => {
const p = join(tmpDir, 'config.yaml')
writeFileSync(p, 'platforms:\n cli:\n enabled: true\n')
expect(disableExclusivePlatformsInConfig(p))
.toEqual({ disabled: [], strippedConfigCredentials: [] })
expect(readdirSync(tmpDir).filter(f => f.startsWith('config.yaml.bak'))).toHaveLength(0)
})
it('disables enabled exclusive platforms, strips embedded credentials, and backs up', () => {
const p = join(tmpDir, 'config.yaml')
writeFileSync(p, [
'platforms:',
' cli:',
' enabled: true',
' weixin:',
' enabled: true',
' token: secret',
' extra:',
' account_id: acct-1',
' app_id: app-1',
' telegram:',
' enabled: true',
' bot_token: tg-token',
' discord:',
' enabled: false',
'',
].join('\n'))
const result = disableExclusivePlatformsInConfig(p)
expect(result.disabled.sort()).toEqual(['telegram', 'weixin'])
// 节点直挂 + extra 子节点的凭据都应该被清掉
expect(result.strippedConfigCredentials.sort()).toEqual([
'telegram.bot_token',
'weixin.extra.account_id',
'weixin.extra.app_id',
'weixin.token',
])
const after = readFileSync(p, 'utf-8')
expect(after).toMatch(/weixin:[\s\S]*?enabled:\s*false/)
expect(after).toMatch(/telegram:[\s\S]*?enabled:\s*false/)
expect(after).toMatch(/cli:[\s\S]*?enabled:\s*true/)
// 凭据已被清除
expect(after).not.toContain('secret')
expect(after).not.toContain('tg-token')
expect(after).not.toContain('acct-1')
const backups = readdirSync(tmpDir).filter(f => f.startsWith('config.yaml.bak'))
expect(backups).toHaveLength(1)
})
it('strips embedded credentials even when platform is already disabled', () => {
const p = join(tmpDir, 'config.yaml')
writeFileSync(p, [
'platforms:',
' weixin:',
' enabled: false',
' token: leftover-secret',
'',
].join('\n'))
const result = disableExclusivePlatformsInConfig(p)
expect(result.disabled).toEqual([])
expect(result.strippedConfigCredentials).toEqual(['weixin.token'])
const after = readFileSync(p, 'utf-8')
expect(after).not.toContain('leftover-secret')
})
it('returns empty on malformed yaml without throwing', () => {
const p = join(tmpDir, 'config.yaml')
writeFileSync(p, 'platforms: [unclosed')
expect(disableExclusivePlatformsInConfig(p))
.toEqual({ disabled: [], strippedConfigCredentials: [] })
})
})
describe('EXCLUSIVE_PLATFORMS list', () => {
it('stays in sync with the env pattern list (same length)', () => {
expect(EXCLUSIVE_PLATFORMS.length).toBe(EXCLUSIVE_PLATFORM_ENV_PATTERNS.length)
})
})