修复: 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:
@@ -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 }">▾</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)"
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'アクセストークン',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user