Files
Hermes-ui/packages/server/src/services/hermes/profile-credentials.ts
T
ww 2ae7e7ad1b 修复: 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>
2026-04-29 20:31:24 +08:00

188 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 智能克隆 Profile 凭据管理
*
* 背景:`hermes profile create --clone` 会完整复制源 profile 的 .env + config.yaml
* 包括各平台的独占凭据(Weixin / Telegram / Slack / ...)。
* 这导致多个 profile 同时持有同一个 bot tokenhermes-agent 内部的 token 互斥机制
* 会让后启动的 gateway 在健康检查阶段被 kill,表现为"profile 加载错误"。
*
* 解决方案:clone 完成后,对新 profile 自动执行:
* 1. 从 .env 中删除所有匹配独占平台前缀的 KEY
* 2. 把 config.yaml 中独占平台的 `enabled: true` 改为 false
* 操作前会备份原文件为 `.bak.<timestamp>`。
*/
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import yaml from 'js-yaml'
const HERMES_BASE = join(homedir(), '.hermes')
/**
* 已知"独占型"平台的环境变量前缀正则
*
* 这些平台的凭据本质上是"一对一身份绑定":一个 token / app_id 对应唯一一个机器人或账号。
* 多个 profile 共享同一凭据会触发 hermes-agent 的 token 互斥机制 → 启动失败。
*
* 不在此列表的(模型 provider API key、工具调试开关等)视为可安全共享。
*
* **来源(不要凭主观推测扩展)**:与 hermes-agent `gateway/platforms/` 中实际调用
* `_acquire_platform_lock` / `acquire_scoped_lock` 的 adapter 1:1 对齐。
* 验证方法:`grep -l _acquire_platform_lock gateway/platforms/*.py`。
* 当前匹配上游的 7 个:discord, feishu, signal, slack, telegram, weixin, whatsapp。
*/
export const EXCLUSIVE_PLATFORM_ENV_PATTERNS: RegExp[] = [
/^TELEGRAM_/, // Telegram bot
/^DISCORD_/, // Discord bot
/^SLACK_/, // Slack app
/^WHATSAPP_/, // WhatsApp Business
/^SIGNAL_/, // Signal
/^WEIXIN_/, // 个人微信 bot
/^FEISHU_/, // 飞书
]
/**
* 已知"独占型"平台在 config.yaml 中 `platforms.<name>` 节点的名称集合
* 与 EXCLUSIVE_PLATFORM_ENV_PATTERNS 一一对应,用于禁用 `enabled` 字段。
*/
export const EXCLUSIVE_PLATFORMS = [
'telegram', 'discord', 'slack', 'whatsapp', 'signal', 'weixin', 'feishu',
]
/**
* config.yaml 中独占平台节点下的"敏感凭据字段"黑名单
*
* 仅在 EXCLUSIVE_PLATFORMS 节点(含其 `extra` 子节点)下作用,避免误伤模型 provider key
* 等其他配置。clone 时这些字段会被一并删除,防止用户后续 re-enable 平台时复用源 profile
* 的身份。
*/
export const EXCLUSIVE_PLATFORM_CREDENTIAL_KEYS = [
'token', 'bot_token', 'app_token',
'signing_secret', 'app_secret', 'client_secret',
'access_token', 'webhook_secret',
'account_id', 'phone_number_id', 'app_id',
]
/** 判断 .env 中的 KEY 是否属于独占平台凭据 */
export function isExclusivePlatformKey(key: string): boolean {
return EXCLUSIVE_PLATFORM_ENV_PATTERNS.some(re => re.test(key))
}
/**
* 清理 .env 文件中的独占平台凭据
* @param envPath .env 文件绝对路径
* @returns 被删除的 KEY 名列表(按 .env 中出现顺序);文件不存在或无需删除时返回 []
*
* 副作用:实际删除前会备份为 `.env.bak.<timestamp>`,便于用户恢复。
*/
export function stripExclusivePlatformCredentials(envPath: string): string[] {
if (!existsSync(envPath)) return []
const original = readFileSync(envPath, 'utf-8')
const lines = original.split('\n')
const removedKeys: string[] = []
const kept: string[] = []
for (const line of lines) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/)
if (m && isExclusivePlatformKey(m[1])) {
removedKeys.push(m[1])
} else {
kept.push(line)
}
}
if (removedKeys.length === 0) return []
writeFileSync(`${envPath}.bak.${Date.now()}`, original, 'utf-8')
writeFileSync(envPath, kept.join('\n'), 'utf-8')
return removedKeys
}
/**
* 禁用 config.yaml 中已知独占平台的 enabled 字段,并清理节点下的敏感凭据
* @param configPath config.yaml 绝对路径
* @returns
* - disabled: 被禁用的平台名列表
* - strippedConfigCredentials: 被清理的凭据字段路径(如 'weixin.extra.token'
* 无任何修改时两个字段均为空数组。
*
* 副作用:实际改写前会备份为 `config.yaml.bak.<timestamp>`。
*/
export function disableExclusivePlatformsInConfig(configPath: string): {
disabled: string[]
strippedConfigCredentials: string[]
} {
if (!existsSync(configPath)) return { disabled: [], strippedConfigCredentials: [] }
const original = readFileSync(configPath, 'utf-8')
let cfg: any
try {
cfg = yaml.load(original)
} catch {
return { disabled: [], strippedConfigCredentials: [] }
}
if (!cfg || typeof cfg !== 'object') return { disabled: [], strippedConfigCredentials: [] }
const platforms = cfg.platforms
if (!platforms || typeof platforms !== 'object') return { disabled: [], strippedConfigCredentials: [] }
const disabled: string[] = []
const strippedConfigCredentials: string[] = []
for (const platName of EXCLUSIVE_PLATFORMS) {
const node = platforms[platName]
if (!node || typeof node !== 'object') continue
if (node.enabled === true) {
node.enabled = false
disabled.push(platName)
}
// 清理节点直挂的凭据字段
for (const k of EXCLUSIVE_PLATFORM_CREDENTIAL_KEYS) {
if (k in node) {
delete node[k]
strippedConfigCredentials.push(`${platName}.${k}`)
}
}
// 清理 extra 子节点中的凭据字段
if (node.extra && typeof node.extra === 'object') {
for (const k of EXCLUSIVE_PLATFORM_CREDENTIAL_KEYS) {
if (k in node.extra) {
delete node.extra[k]
strippedConfigCredentials.push(`${platName}.extra.${k}`)
}
}
}
}
if (disabled.length === 0 && strippedConfigCredentials.length === 0) {
return { disabled: [], strippedConfigCredentials: [] }
}
writeFileSync(`${configPath}.bak.${Date.now()}`, original, 'utf-8')
writeFileSync(configPath, yaml.dump(cfg, { lineWidth: -1 }), 'utf-8')
return { disabled, strippedConfigCredentials }
}
export interface SmartCloneCleanup {
/** 从 .env 中删除的 KEY 名列表 */
strippedCredentials: string[]
/** 在 config.yaml 中被禁用的平台名列表 */
disabledPlatforms: string[]
/** 在 config.yaml 中被清理的内嵌凭据字段路径(如 'weixin.extra.token' */
strippedConfigCredentials: string[]
}
/**
* 一站式:清理新 profile 的独占凭据 + 禁用 config.yaml 中的独占平台
*
* @param profileName profile 名称('default' → ~/.hermes/,其他 → ~/.hermes/profiles/<name>/
*/
export function smartCloneCleanup(profileName: string): SmartCloneCleanup {
const profileDir = profileName === 'default'
? HERMES_BASE
: join(HERMES_BASE, 'profiles', profileName)
const configResult = disableExclusivePlatformsInConfig(join(profileDir, 'config.yaml'))
return {
strippedCredentials: stripExclusivePlatformCredentials(join(profileDir, '.env')),
disabledPlatforms: configResult.disabled,
strippedConfigCredentials: configResult.strippedConfigCredentials,
}
}