修复: 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:
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 智能克隆 Profile 凭据管理
|
||||
*
|
||||
* 背景:`hermes profile create --clone` 会完整复制源 profile 的 .env + config.yaml,
|
||||
* 包括各平台的独占凭据(Weixin / Telegram / Slack / ...)。
|
||||
* 这导致多个 profile 同时持有同一个 bot token,hermes-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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user