修复: 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
+5
View File
@@ -335,6 +335,10 @@ export default {
newName: 'Neuer Name',
newNamePlaceholder: 'Neuen Namen eingeben',
cloneFromCurrent: 'Aus aktuellem Profil klonen',
cloneCleanupNotice: 'Beim Klonen werden exklusive Plattform-Anmeldeinformationen (Weixin / Telegram / Slack usw.) automatisch übersprungen, um Konflikte mit dem Quellprofil zu vermeiden',
cloneStrippedCredentials: '{count} exklusive Anmeldeinformation(en) entfernt: {list}',
cloneDisabledPlatforms: '{count} Plattform(en) deaktiviert: {list}',
cloneStrippedConfigCredentials: '{count} eingebettete Anmeldeinformation(en) aus config.yaml entfernt: {list}',
archivePath: 'Archivpfad',
archivePathPlaceholder: 'Serverpfad zur Archivdatei',
importName: 'Profilname (optional)',
@@ -480,6 +484,7 @@ export default {
ignoredChannelsHint: 'Kanale, in denen der Bot nie antwortet (komma-getrennt)',
noThreadChannels: 'Thread-lose Kanale',
noThreadChannelsHint: 'Kanale, in denen der Bot ohne Threads antwortet (komma-getrennt)',
exclusiveTokenWarning: 'Diese Plattform verwendet einen exklusiven Token-Lock. Jedes Profil muss einen anderen Identitäts-Token verwenden, um Konflikte mit anderen Profilen zu vermeiden.',
botToken: 'Bot-Token',
botTokenHint: 'Bot-Token vom Entwicklerportal',
accessToken: 'Zugangs-Token',
+5
View File
@@ -367,6 +367,10 @@ export default {
newName: 'New Name',
newNamePlaceholder: 'Enter new name',
cloneFromCurrent: 'Clone from current profile',
cloneCleanupNotice: 'Cloning automatically skips exclusive platform credentials (Weixin / Telegram / Slack, etc.) to avoid conflicts with the source profile',
cloneStrippedCredentials: 'Stripped {count} exclusive credential(s): {list}',
cloneDisabledPlatforms: 'Disabled {count} platform(s): {list}',
cloneStrippedConfigCredentials: 'Stripped {count} embedded credential(s) from config.yaml: {list}',
archivePath: 'Archive Path',
archivePathPlaceholder: 'Server path to archive file',
importName: 'Profile Name (optional)',
@@ -521,6 +525,7 @@ export default {
ignoredChannelsHint: 'Channels where bot never responds (comma-separated)',
noThreadChannels: 'No-Thread Channels',
noThreadChannelsHint: 'Channels where bot responds without threads (comma-separated)',
exclusiveTokenWarning: 'This platform uses exclusive token locking. Each profile must use a different identity token to avoid conflicts with other profiles.',
botToken: 'Bot Token',
botTokenHint: 'Bot token from developer portal',
accessToken: 'Access Token',
+5
View File
@@ -335,6 +335,10 @@ export default {
newName: 'Nuevo nombre',
newNamePlaceholder: 'Introduce un nuevo nombre',
cloneFromCurrent: 'Clonar desde el perfil actual',
cloneCleanupNotice: 'Al clonar se omiten automáticamente las credenciales exclusivas de plataforma (Weixin / Telegram / Slack, etc.) para evitar conflictos con el perfil de origen',
cloneStrippedCredentials: 'Se eliminaron {count} credenciales exclusivas: {list}',
cloneDisabledPlatforms: 'Se deshabilitaron {count} plataforma(s): {list}',
cloneStrippedConfigCredentials: 'Se eliminaron {count} credencial(es) integradas de config.yaml: {list}',
archivePath: 'Ruta del archivo',
archivePathPlaceholder: 'Ruta del servidor al archivo de archivo',
importName: 'Nombre del perfil (opcional)',
@@ -480,6 +484,7 @@ export default {
ignoredChannelsHint: 'Canales donde el bot nunca responde (separados por comas)',
noThreadChannels: 'Canales sin hilo',
noThreadChannelsHint: 'Canales donde el bot responde sin hilos (separados por comas)',
exclusiveTokenWarning: 'Esta plataforma usa bloqueo exclusivo de token. Cada perfil debe usar un token de identidad distinto para evitar conflictos con otros perfiles.',
botToken: 'Token del bot',
botTokenHint: 'Token del bot del portal de desarrolladores',
accessToken: 'Token de acceso',
+5
View File
@@ -335,6 +335,10 @@ export default {
newName: 'Nouveau nom',
newNamePlaceholder: 'Entrez un nouveau nom',
cloneFromCurrent: 'Cloner depuis le profil actuel',
cloneCleanupNotice: 'Lors du clonage, les identifiants de plateformes exclusives (Weixin / Telegram / Slack, etc.) sont automatiquement ignorés pour éviter les conflits avec le profil source',
cloneStrippedCredentials: '{count} identifiant(s) exclusif(s) supprimé(s) : {list}',
cloneDisabledPlatforms: '{count} plateforme(s) désactivée(s) : {list}',
cloneStrippedConfigCredentials: '{count} identifiant(s) intégré(s) supprimé(s) de config.yaml : {list}',
archivePath: 'Chemin de l\'archive',
archivePathPlaceholder: 'Chemin serveur du fichier d\'archive',
importName: 'Nom du profil (facultatif)',
@@ -480,6 +484,7 @@ export default {
ignoredChannelsHint: 'Canaux ou le bot ne repond jamais (separes par des virgules)',
noThreadChannels: 'Canaux sans fil',
noThreadChannelsHint: 'Canaux ou le bot repond sans fil (separes par des virgules)',
exclusiveTokenWarning: 'Cette plateforme utilise un verrou de jeton exclusif. Chaque profil doit utiliser un jeton d\'identite different pour eviter les conflits avec les autres profils.',
botToken: 'Jeton de bot',
botTokenHint: 'Jeton de bot depuis le portail developpeur',
accessToken: 'Jeton d\'acces',
+5
View File
@@ -335,6 +335,10 @@ export default {
newName: '新しい名前',
newNamePlaceholder: '新しい名前を入力',
cloneFromCurrent: '現在のプロファイルから複製',
cloneCleanupNotice: '複製時、独占型プラットフォーム認証情報(Weixin / Telegram / Slack など)は自動的にスキップされ、ソースプロファイルとの競合を回避します',
cloneStrippedCredentials: '{count} 件の独占認証情報を削除しました:{list}',
cloneDisabledPlatforms: '{count} 個のプラットフォームを無効化しました:{list}',
cloneStrippedConfigCredentials: 'config.yaml から {count} 件の埋め込み認証情報を削除しました:{list}',
archivePath: 'アーカイブパス',
archivePathPlaceholder: 'アーカイブファイルのサーバーパス',
importName: 'プロファイル名(任意)',
@@ -480,6 +484,7 @@ export default {
ignoredChannelsHint: 'ボットが応答しないチャンネル ID(カンマ区切り)',
noThreadChannels: 'スレッドなしチャンネル',
noThreadChannelsHint: 'スレッドなしで応答するチャンネル ID(カンマ区切り)',
exclusiveTokenWarning: 'このプラットフォームは排他的トークンロックを使用します。各プロファイルは他のプロファイルと競合しないように、異なる ID トークンを使用する必要があります。',
botToken: 'ボットトークン',
botTokenHint: '開発者ポータルから取得したボットトークン',
accessToken: 'アクセストークン',
+5
View File
@@ -335,6 +335,10 @@ export default {
newName: '새 이름',
newNamePlaceholder: '새 이름을 입력하세요',
cloneFromCurrent: '현재 프로필에서 복제',
cloneCleanupNotice: '복제 시 독점형 플랫폼 자격 증명(Weixin / Telegram / Slack 등)은 자동으로 건너뛰어 원본 프로필과의 충돌을 방지합니다',
cloneStrippedCredentials: '독점 자격 증명 {count}개 제거됨: {list}',
cloneDisabledPlatforms: '플랫폼 {count}개 비활성화됨: {list}',
cloneStrippedConfigCredentials: 'config.yaml에서 임베디드 자격 증명 {count}개 제거됨: {list}',
archivePath: '아카이브 경로',
archivePathPlaceholder: '아카이브 파일의 서버 경로',
importName: '프로필 이름 (선택)',
@@ -480,6 +484,7 @@ export default {
ignoredChannelsHint: '봇이 응답하지 않는 채널 ID (쉼표로 구분)',
noThreadChannels: '스레드 없는 채널',
noThreadChannelsHint: '스레드 없이 응답할 채널 ID (쉼표로 구분)',
exclusiveTokenWarning: '이 플랫폼은 독점 토큰 잠금을 사용합니다. 각 프로필은 다른 프로필과 충돌하지 않도록 서로 다른 ID 토큰을 사용해야 합니다.',
botToken: 'Bot Token',
botTokenHint: '개발자 포털에서 발급받은 Bot Token',
accessToken: 'Access Token',
+5
View File
@@ -335,6 +335,10 @@ export default {
newName: 'Novo nome',
newNamePlaceholder: 'Digite um novo nome',
cloneFromCurrent: 'Clonar do perfil atual',
cloneCleanupNotice: 'Ao clonar, as credenciais exclusivas de plataforma (Weixin / Telegram / Slack, etc.) são automaticamente ignoradas para evitar conflitos com o perfil de origem',
cloneStrippedCredentials: '{count} credencial(is) exclusiva(s) removida(s): {list}',
cloneDisabledPlatforms: '{count} plataforma(s) desabilitada(s): {list}',
cloneStrippedConfigCredentials: '{count} credencial(is) incorporada(s) removida(s) do config.yaml: {list}',
archivePath: 'Caminho do arquivo',
archivePathPlaceholder: 'Caminho do servidor para o arquivo',
importName: 'Nome do perfil (opcional)',
@@ -480,6 +484,7 @@ export default {
ignoredChannelsHint: 'Canais onde o bot nunca responde (separados por virgula)',
noThreadChannels: 'Canais sem thread',
noThreadChannelsHint: 'Canais onde o bot responde sem threads (separados por virgula)',
exclusiveTokenWarning: 'Esta plataforma usa bloqueio exclusivo de token. Cada perfil deve usar um token de identidade diferente para evitar conflitos com outros perfis.',
botToken: 'Token do bot',
botTokenHint: 'Token do bot do portal do desenvolvedor',
accessToken: 'Token de acesso',
+5
View File
@@ -359,6 +359,10 @@ export default {
newName: '新名称',
newNamePlaceholder: '输入新名称',
cloneFromCurrent: '从当前配置克隆',
cloneCleanupNotice: '克隆时会自动跳过独占型平台凭据(Weixin / Telegram / Slack 等),避免与源配置冲突',
cloneStrippedCredentials: '已清理 {count} 项独占凭据:{list}',
cloneDisabledPlatforms: '已禁用 {count} 个平台:{list}',
cloneStrippedConfigCredentials: '已清理 config.yaml 中 {count} 项内嵌凭据:{list}',
archivePath: '归档路径',
archivePathPlaceholder: '归档文件的服务器路径',
importName: '配置名称(可选)',
@@ -513,6 +517,7 @@ export default {
ignoredChannelsHint: '不响应的频道 ID(逗号分隔)',
noThreadChannels: '无线程频道',
noThreadChannelsHint: '不创建线程的频道 ID(逗号分隔)',
exclusiveTokenWarning: '此平台使用独占 token 锁。每个 profile 必须使用不同的身份 token,否则会与其他 profile 冲突导致 gateway 启动失败。',
botToken: 'Bot Token',
botTokenHint: '开发者门户获取的 Bot Token',
accessToken: 'Access Token',