修复: 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
+24 -4
View File
@@ -29,15 +29,35 @@ export async function fetchProfileDetail(name: string): Promise<HermesProfileDet
return res.profile
}
export async function createProfile(name: string, clone?: boolean): Promise<boolean> {
export interface CreateProfileResult {
success: boolean
/** clone=true 时被清理的独占平台凭据 KEY 名 */
strippedCredentials?: string[]
/** clone=true 时被禁用的独占平台名 */
disabledPlatforms?: string[]
/** clone=true 时在 config.yaml 中被清理的内嵌凭据字段路径 */
strippedConfigCredentials?: string[]
}
export async function createProfile(name: string, clone?: boolean): Promise<CreateProfileResult> {
try {
await request('/api/hermes/profiles', {
const res = await request<{
success: boolean
strippedCredentials?: string[]
disabledPlatforms?: string[]
strippedConfigCredentials?: string[]
}>('/api/hermes/profiles', {
method: 'POST',
body: JSON.stringify({ name, clone }),
})
return true
return {
success: !!res.success,
strippedCredentials: res.strippedCredentials,
disabledPlatforms: res.disabledPlatforms,
strippedConfigCredentials: res.strippedConfigCredentials,
}
} catch {
return false
return { success: false }
}
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSwitch, useMessage } from 'naive-ui'
import { NModal, NForm, NFormItem, NInput, NButton, NSwitch, NText, useMessage } from 'naive-ui'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useI18n } from 'vue-i18n'
@@ -26,9 +26,20 @@ async function handleSave() {
loading.value = true
try {
const ok = await profilesStore.createProfile(name.value.trim(), clone.value)
if (ok) {
message.success(t('profiles.createSuccess', { name: name.value.trim() }))
const res = await profilesStore.createProfile(name.value.trim(), clone.value)
if (res.success) {
const stripped = res.strippedCredentials ?? []
const disabled = res.disabledPlatforms ?? []
const cfgStripped = res.strippedConfigCredentials ?? []
if (clone.value && (stripped.length > 0 || disabled.length > 0 || cfgStripped.length > 0)) {
const parts: string[] = []
if (stripped.length > 0) parts.push(t('profiles.cloneStrippedCredentials', { count: stripped.length, list: stripped.join(', ') }))
if (disabled.length > 0) parts.push(t('profiles.cloneDisabledPlatforms', { count: disabled.length, list: disabled.join(', ') }))
if (cfgStripped.length > 0) parts.push(t('profiles.cloneStrippedConfigCredentials', { count: cfgStripped.length, list: cfgStripped.join(', ') }))
message.info(`${t('profiles.createSuccess', { name: name.value.trim() })}\n${parts.join('\n')}`, { duration: 6000 })
} else {
message.success(t('profiles.createSuccess', { name: name.value.trim() }))
}
emit('saved')
} else {
message.error(t('profiles.createFailed'))
@@ -66,6 +77,9 @@ function handleClose() {
<NFormItem :label="t('profiles.cloneFromCurrent')">
<NSwitch v-model:value="clone" />
</NFormItem>
<NText v-if="clone" depth="3" style="font-size: 12px;">
{{ t('profiles.cloneCleanupNotice') }}
</NText>
</NForm>
<template #footer>
@@ -86,3 +100,11 @@ function handleClose() {
gap: 8px;
}
</style>
<style scoped lang="scss">
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NTag } from 'naive-ui'
import { NTag, NAlert } from 'naive-ui'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
@@ -8,6 +8,7 @@ const props = defineProps<{
icon: string
config: Record<string, any>
credentials?: Record<string, any>
exclusive?: boolean
}>()
const expanded = ref(true)
@@ -41,6 +42,9 @@ const configured = computed(() => {
<span class="expand-icon" :class="{ expanded }">&#9662;</span>
</div>
<div v-if="expanded" class="platform-card-body">
<NAlert v-if="exclusive" type="warning" :show-icon="true" class="exclusive-alert">
{{ t('platform.exclusiveTokenWarning') }}
</NAlert>
<slot />
</div>
</div>
@@ -111,4 +115,9 @@ const configured = computed(() => {
padding: 0 16px 12px;
border-top: 1px solid $border-light;
}
.exclusive-alert {
margin: 12px 0 4px;
font-size: 12px;
}
</style>
@@ -120,21 +120,25 @@ const platforms = [
{
key: 'telegram',
name: 'Telegram',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
},
{
key: 'discord',
name: 'Discord',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg>',
},
{
key: 'slack',
name: 'Slack',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 0a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V5.042zm-1.27 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.27a2.527 2.527 0 0 1 2.523-2.52h6.313A2.528 2.528 0 0 1 24 18.956a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>',
},
{
key: 'whatsapp',
name: 'WhatsApp',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>',
},
{
@@ -145,11 +149,13 @@ const platforms = [
{
key: 'feishu',
name: 'Feishu',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.59 3.41a2.25 2.25 0 0 1 3.182 0L13.5 7.14l-3.182 3.182L6.59 7.59a2.25 2.25 0 0 1 0-3.182zm5.303 5.303L15.075 5.53a2.25 2.25 0 0 1 3.182 3.182L15.075 11.894 11.893 8.713zM3.41 6.59a2.25 2.25 0 0 1 3.182 0l3.182 3.182-3.182 3.182a2.25 2.25 0 0 1-3.182-3.182L3.41 6.59zm5.303 5.303L11.894 15.075a2.25 2.25 0 0 1-3.182 3.182L5.53 15.075 8.713 11.893zm5.303-5.303L17.478 9.778a2.25 2.25 0 0 1-3.182 3.182L10.53 10.075l3.182-3.182 0 .023z"/></svg>',
},
{
key: 'weixin',
name: 'Weixin',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
},
{
@@ -167,6 +173,7 @@ const platforms = [
:key="p.key"
:name="p.name"
:icon="p.icon"
:exclusive="p.exclusive"
:config="settingsStore[p.key as keyof typeof settingsStore] as Record<string, any>"
:credentials="getCreds(p.key)"
>
+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',
@@ -43,9 +43,9 @@ export const useProfilesStore = defineStore('profiles', () => {
}
async function createProfile(name: string, clone?: boolean) {
const ok = await profilesApi.createProfile(name, clone)
if (ok) await fetchProfiles()
return ok
const res = await profilesApi.createProfile(name, clone)
if (res.success) await fetchProfiles()
return res
}
async function deleteProfile(name: string) {
@@ -6,6 +6,7 @@ import * as hermesCli from '../../services/hermes/hermes-cli'
import { SessionDeleter } from '../../services/hermes/session-deleter'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { logger } from '../../services/logger'
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
export async function list(ctx: any) {
try {
@@ -26,13 +27,52 @@ export async function create(ctx: any) {
}
try {
const output = await hermesCli.createProfile(name, clone)
// clone=true 时执行智能清理:
// - 删除 .env 中的独占平台凭据(Weixin / Telegram / Slack / ...
// - 禁用 config.yaml 中对应的平台节点
// 避免新 profile 与源 profile 共享同一个 bot token 导致互斥冲突。
let strippedCredentials: string[] = []
let disabledPlatforms: string[] = []
let strippedConfigCredentials: string[] = []
if (clone) {
try {
const cleanup = smartCloneCleanup(name)
strippedCredentials = cleanup.strippedCredentials
disabledPlatforms = cleanup.disabledPlatforms
strippedConfigCredentials = cleanup.strippedConfigCredentials
if (
strippedCredentials.length > 0 ||
disabledPlatforms.length > 0 ||
strippedConfigCredentials.length > 0
) {
logger.info(
'Smart clone cleanup for "%s": stripped %d env credentials (%s), disabled %d platforms (%s), stripped %d config credentials (%s)',
name,
strippedCredentials.length, strippedCredentials.join(','),
disabledPlatforms.length, disabledPlatforms.join(','),
strippedConfigCredentials.length, strippedConfigCredentials.join(','),
)
}
} catch (err: any) {
// 清理失败不应阻断 profile 创建,仅记日志
logger.error(err, 'Smart clone cleanup failed for "%s"', name)
}
}
const mgr = getGatewayManagerInstance()
if (mgr) {
try { await mgr.start(name) } catch (err: any) {
logger.error(err, 'Failed to start gateway for profile "%s"', name)
}
}
ctx.body = { success: true, message: output.trim() }
ctx.body = {
success: true,
message: output.trim(),
strippedCredentials,
disabledPlatforms,
strippedConfigCredentials,
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
@@ -0,0 +1,187 @@
/**
* 智能克隆 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,
}
}
+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)
})
})