* 修复: 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>
250 lines
8.3 KiB
TypeScript
250 lines
8.3 KiB
TypeScript
import { createReadStream, existsSync, unlinkSync, writeFileSync } from 'fs'
|
||
import { mkdir, writeFile } from 'fs/promises'
|
||
import { basename, join } from 'path'
|
||
import { tmpdir } from 'os'
|
||
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 {
|
||
const profiles = await hermesCli.listProfiles()
|
||
ctx.body = { profiles }
|
||
} catch (err: any) {
|
||
ctx.status = 500
|
||
ctx.body = { error: err.message }
|
||
}
|
||
}
|
||
|
||
export async function create(ctx: any) {
|
||
const { name, clone } = ctx.request.body as { name?: string; clone?: boolean }
|
||
if (!name) {
|
||
ctx.status = 400
|
||
ctx.body = { error: 'Missing profile name' }
|
||
return
|
||
}
|
||
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(),
|
||
strippedCredentials,
|
||
disabledPlatforms,
|
||
strippedConfigCredentials,
|
||
}
|
||
} catch (err: any) {
|
||
ctx.status = 500
|
||
ctx.body = { error: err.message }
|
||
}
|
||
}
|
||
|
||
export async function get(ctx: any) {
|
||
try {
|
||
const profile = await hermesCli.getProfile(ctx.params.name)
|
||
ctx.body = { profile }
|
||
} catch (err: any) {
|
||
ctx.status = err.message.includes('not found') ? 404 : 500
|
||
ctx.body = { error: err.message }
|
||
}
|
||
}
|
||
|
||
export async function remove(ctx: any) {
|
||
const { name } = ctx.params
|
||
if (name === 'default') {
|
||
ctx.status = 400
|
||
ctx.body = { error: 'Cannot delete the default profile' }
|
||
return
|
||
}
|
||
try {
|
||
const mgr = getGatewayManagerInstance()
|
||
if (mgr) { try { await mgr.stop(name) } catch { } }
|
||
const ok = await hermesCli.deleteProfile(name)
|
||
if (ok) {
|
||
ctx.body = { success: true }
|
||
} else {
|
||
ctx.status = 500
|
||
ctx.body = { error: 'Failed to delete profile' }
|
||
}
|
||
} catch (err: any) {
|
||
ctx.status = 500
|
||
ctx.body = { error: err.message }
|
||
}
|
||
}
|
||
|
||
export async function rename(ctx: any) {
|
||
const { new_name } = ctx.request.body as { new_name?: string }
|
||
if (!new_name) {
|
||
ctx.status = 400
|
||
ctx.body = { error: 'Missing new_name' }
|
||
return
|
||
}
|
||
try {
|
||
const ok = await hermesCli.renameProfile(ctx.params.name, new_name)
|
||
if (ok) {
|
||
ctx.body = { success: true }
|
||
} else {
|
||
ctx.status = 500
|
||
ctx.body = { error: 'Failed to rename profile' }
|
||
}
|
||
} catch (err: any) {
|
||
ctx.status = 500
|
||
ctx.body = { error: err.message }
|
||
}
|
||
}
|
||
|
||
export async function switchProfile(ctx: any) {
|
||
const { name } = ctx.request.body as { name?: string }
|
||
if (!name) {
|
||
ctx.status = 400
|
||
ctx.body = { error: 'Missing profile name' }
|
||
return
|
||
}
|
||
try {
|
||
const output = await hermesCli.useProfile(name)
|
||
await new Promise(r => setTimeout(r, 1000))
|
||
const mgr = getGatewayManagerInstance()
|
||
if (mgr) { mgr.setActiveProfile(name) }
|
||
try {
|
||
const detail = await hermesCli.getProfile(name)
|
||
logger.debug('Profile detail.path = %s', detail.path)
|
||
if (!existsSync(join(detail.path, 'config.yaml'))) {
|
||
try { await hermesCli.setupReset() } catch { }
|
||
}
|
||
const profileEnv = join(detail.path, '.env')
|
||
if (!existsSync(profileEnv)) {
|
||
writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8')
|
||
logger.info('Created .env for: %s', detail.path)
|
||
}
|
||
} catch (err: any) {
|
||
logger.error(err, 'Ensure config failed')
|
||
}
|
||
const drainResult = await SessionDeleter.getInstance().drain(name)
|
||
SessionDeleter.getInstance().switchProfile(name)
|
||
logger.info('[switchProfile] drain result for profile "%s": %d deleted, %d failed', name, drainResult.deleted.length, drainResult.failed.length)
|
||
if (drainResult.failed.length > 0) {
|
||
logger.warn({ profile: name, failed: drainResult.failed }, 'Failed to drain some pending session deletes after profile switch')
|
||
}
|
||
ctx.body = {
|
||
success: true,
|
||
message: output.trim(),
|
||
drained_session_deletes: drainResult.deleted.length,
|
||
failed_session_deletes: drainResult.failed.length,
|
||
}
|
||
} catch (err: any) {
|
||
ctx.status = 500
|
||
ctx.body = { error: err.message }
|
||
}
|
||
}
|
||
|
||
export async function exportProfile(ctx: any) {
|
||
const { name } = ctx.params
|
||
const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`)
|
||
try {
|
||
await hermesCli.exportProfile(name, outputPath)
|
||
if (!existsSync(outputPath)) {
|
||
ctx.status = 500
|
||
ctx.body = { error: 'Export file not found' }
|
||
return
|
||
}
|
||
const filename = basename(outputPath)
|
||
ctx.set('Content-Disposition', `attachment; filename="${filename}"`)
|
||
ctx.set('Content-Type', 'application/gzip')
|
||
ctx.body = createReadStream(outputPath)
|
||
ctx.res.on('finish', () => { try { unlinkSync(outputPath) } catch { } })
|
||
} catch (err: any) {
|
||
ctx.status = 500
|
||
ctx.body = { error: err.message }
|
||
}
|
||
}
|
||
|
||
export async function importProfile(ctx: any) {
|
||
const contentType = ctx.get('content-type') || ''
|
||
if (!contentType.startsWith('multipart/form-data')) {
|
||
ctx.status = 400
|
||
ctx.body = { error: 'Expected multipart/form-data' }
|
||
return
|
||
}
|
||
const boundary = '--' + contentType.split('boundary=')[1]
|
||
if (!boundary || boundary === '--undefined') {
|
||
ctx.status = 400
|
||
ctx.body = { error: 'Missing boundary' }
|
||
return
|
||
}
|
||
const tmpDir = join(tmpdir(), 'hermes-import')
|
||
await mkdir(tmpDir, { recursive: true })
|
||
const chunks: Buffer[] = []
|
||
for await (const chunk of ctx.req) chunks.push(chunk)
|
||
const body = Buffer.concat(chunks).toString('latin1')
|
||
const parts = body.split(boundary).slice(1, -1)
|
||
let archivePath = ''
|
||
for (const part of parts) {
|
||
const headerEnd = part.indexOf('\r\n\r\n')
|
||
if (headerEnd === -1) continue
|
||
const header = part.substring(0, headerEnd)
|
||
const data = part.substring(headerEnd + 4, part.length - 2)
|
||
const filenameMatch = header.match(/filename="([^"]+)"/)
|
||
if (!filenameMatch) continue
|
||
const filename = filenameMatch[1]
|
||
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
|
||
if (!['.gz', '.tar.gz', '.zip', '.tgz'].includes(ext)) continue
|
||
archivePath = join(tmpDir, filename)
|
||
await writeFile(archivePath, Buffer.from(data, 'binary'))
|
||
break
|
||
}
|
||
if (!archivePath) {
|
||
ctx.status = 400
|
||
ctx.body = { error: 'No archive file found (.gz, .zip, .tgz)' }
|
||
return
|
||
}
|
||
try {
|
||
const result = await hermesCli.importProfile(archivePath)
|
||
try { unlinkSync(archivePath) } catch { }
|
||
ctx.body = { success: true, message: result.trim() }
|
||
} catch (err: any) {
|
||
try { unlinkSync(archivePath) } catch { }
|
||
ctx.status = 500
|
||
ctx.body = { error: err.message }
|
||
}
|
||
}
|