feat(hermes-server): add Hermes Server with ce-code-review security fixes

实现独立的 Hermes Server 程序,部署在 Hermes 服务器上接收 EternalAI 同步请求。

主要功能:
- 接收 EternalAI 同步请求(POST /api/sync),验证 sync_token (JWT HS256)
- 回调 EternalAI 拉取 SOUL.md 和 config.yaml 文件
- 创建 profile(文件系统存储,无需数据库)
- 生成二维码绑定链接(PNG)
- 提供绑定页面和绑定 API(POST /api/bind/:profileId)
- 提供 profile 管理 API(列表/详情/删除/文件下载)
- 健康检查端点

ce-code-review 安全修复(2 P0, 8 P1, 10 P2, 12 P3):
- P0: SSRF 防护 — 使用 ETERNALAI_BASE_URL 环境变量,不信任请求体中的 filePullBaseUrl
- P0: profileId 路径穿越防护 — 正则校验 /^hermes_[a-f0-9]{24}$/
- P1: 原子文件写入(临时目录 + rename)
- P1: 绑定竞态条件修复(原子检查 alreadyBound)
- P1: jti 重放保护(内存 Map + 5 分钟 TTL)
- P1: 幂等去重(findByRoleId,重复同步先删后建)
- P1: 生产环境 fail-fast(HERMES_ADMIN_TOKEN 未设置时抛错)
- P1: 恒定时间比较 admin token(crypto.timingSafeEqual)
- P1: trust proxy 修复(仅 loopback)
- P1: IP 白名单使用 socket.remoteAddress
- P2: CORS 来源限制(ALLOWED_ORIGINS)
- P2: body 大小限制(1MB)
- P2: configYaml 非空校验
- P2: QR 码生成顺序(先创建 profile 再生成二维码)
- P2: 输入校验(profileName 长度限制)
- P2: listProfiles 容错
- P2: 健康检查不泄露 syncSecretConfigured
- P2: SOUL.md/config.yaml 本机访问限制
- P2: 绑定页面 try/catch
- P2: req.body null check
- P3: JWT 算法限制(仅 HS256)
- P3: 错误类型区分(504/502/500)
- P3: Content-Type 修复(text/markdown, text/yaml)
- P3: 404 状态码修复
- P3: form action 转义(encodeURIComponent)
- P3: deploy.sh 路径修复 + package-lock.json + npm ci

测试:20 个集成测试全部通过(端到端验证同步、二维码、绑定、写保护等)
This commit is contained in:
chiguyong 2026-06-21 17:38:21 +08:00
parent a921f64ee0
commit 561a680771
16 changed files with 2791 additions and 0 deletions

View File

@ -0,0 +1,38 @@
# ===== Hermes Server 环境变量配置 =====
# 服务端口
PORT=3002
# Hermes Server 对外访问的基础 URL用于生成二维码绑定链接
# 必须是外部可访问的地址,二维码内容会基于此生成
HERMES_BASE_URL=http://localhost:3002
# ===== EternalAI 地址P0 修复:用于回调拉取文件,不信任请求体) =====
# 配置为 EternalAI 服务器的可访问地址
# hermes-server 会从此地址拉取 SOUL.md 和 config.yaml
ETERNALAI_BASE_URL=http://localhost:3001
# ===== 与 EternalAI 共享的 sync_token 密钥 =====
# 必须与 EternalAI 的 SYNC_SECRET 一致EternalAI 数据库 SystemConfig 表中 key='SYNC_SECRET' 的值)
# 获取方式:在 EternalAI 服务器执行
# psql -d eternalai -c "SELECT value FROM \"SystemConfig\" WHERE key='SYNC_SECRET'"
# 或通过 Prisma Studio 查看
SYNC_SECRET=
# ===== Hermes 管理员认证 =====
# 管理 API查看 profile 列表等)的 Bearer token
# 生产环境务必设置为强随机值(可用 openssl rand -hex 32 生成)
HERMES_ADMIN_TOKEN=dev_only_admin_token_change_me
# ===== 可选EternalAI 地址白名单 =====
# 限制同步请求来源 IP逗号分隔留空则不限制
# 示例ALLOWED_SOURCE_IPS=192.168.1.100,10.0.0.5
ALLOWED_SOURCE_IPS=
# ===== 可选CORS 来源限制 =====
# 限制跨域请求来源(逗号分隔),留空则非生产环境允许所有来源
# 示例ALLOWED_ORIGINS=https://eternalai.example.com,https://admin.eternalai.example.com
ALLOWED_ORIGINS=
# ===== Node 环境 =====
NODE_ENV=development

4
hermes-server/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
data/
.env
*.log

View File

@ -0,0 +1,132 @@
#!/bin/bash
# Hermes Server 部署脚本
# 在 Hermes 服务器上执行bash deploy/deploy.sh
set -e
APP_DIR="/opt/hermes-server"
APP_USER="hermes"
NODE_VERSION="20"
echo "===== Hermes Server 部署脚本 ====="
# 1. 检查 Node.js
if ! command -v node &> /dev/null; then
echo "安装 Node.js $NODE_VERSION..."
curl -fsSL https://deb.nodesource.com/setup_$NODE_VERSION.x | sudo -E bash -
sudo apt-get install -y nodejs
fi
echo "Node.js 版本: $(node -v)"
# 2. 创建用户和目录
if ! id "$APP_USER" &> /dev/null; then
sudo useradd -r -m -d /home/$APP_USER -s /bin/bash $APP_USER
echo "创建用户: $APP_USER"
fi
sudo mkdir -p $APP_DIR
sudo chown -R $APP_USER:$APP_USER $APP_DIR
# 3. 复制代码(假设当前目录是项目根目录)
echo "复制代码到 $APP_DIR..."
sudo -u $APP_USER cp -r package.json package-lock.json server.js src $APP_DIR/
sudo -u $APP_USER cp -r deploy $APP_DIR/ 2>/dev/null || true
# .env.example 位于项目根目录,而非 deploy/ 子目录
sudo -u $APP_USER cp .env.example $APP_DIR/.env.example 2>/dev/null || true
# 4. 安装依赖
echo "安装依赖..."
cd $APP_DIR
# 优先使用 npm ci依赖 package-lock.json可重现构建失败则回退到 npm install
if [ -f package-lock.json ]; then
sudo -u $APP_USER npm ci --production || sudo -u $APP_USER npm install --production
else
sudo -u $APP_USER npm install --production
fi
# 5. 创建数据目录
sudo -u $APP_USER mkdir -p $APP_DIR/data/profiles $APP_DIR/data/qrcodes
# 6. 配置环境变量
if [ ! -f $APP_DIR/.env ]; then
echo "创建 .env 配置文件..."
# .env.example 已在第 3 步拷贝到 $APP_DIR 根目录
if [ -f $APP_DIR/.env.example ]; then
sudo -u $APP_USER cp $APP_DIR/.env.example $APP_DIR/.env
else
# 如果没有 .env.example创建一个基本的
sudo -u $APP_USER bash -c "cat > $APP_DIR/.env << 'ENVEOF'
PORT=3002
HERMES_BASE_URL=http://localhost:3002
ETERNALAI_BASE_URL=http://localhost:3001
SYNC_SECRET=
HERMES_ADMIN_TOKEN=$(openssl rand -hex 32)
NODE_ENV=production
ENVEOF"
fi
echo "⚠️ 请编辑 $APP_DIR/.env 配置 SYNC_SECRET、HERMES_BASE_URL 和 ETERNALAI_BASE_URL"
fi
# 7. 配置 PM2
echo "配置 PM2..."
if ! command -v pm2 &> /dev/null; then
sudo npm install -g pm2
fi
sudo -u $APP_USER bash -c "cat > $APP_DIR/ecosystem.config.js << 'PM2EOF'
module.exports = {
apps: [{
name: 'hermes-server',
script: 'server.js',
cwd: '$APP_DIR',
instances: 1,
autorestart: true,
max_memory_restart: '256M',
env: {
NODE_ENV: 'production',
},
}],
};
PM2EOF"
pm2 delete hermes-server 2>/dev/null || true
pm2 start $APP_DIR/ecosystem.config.js
pm2 save
# 8. 配置 Nginx可选
NGINX_CONF="/etc/nginx/sites-available/hermes-server"
if command -v nginx &> /dev/null && [ ! -f "$NGINX_CONF" ]; then
echo "配置 Nginx..."
sudo bash -c "cat > $NGINX_CONF << 'NGINXEOF'
server {
listen 80;
server_name _; # 替换为你的域名
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Upgrade \\\$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \\\$host;
proxy_set_header X-Real-IP \\\$remote_addr;
proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \\\$scheme;
proxy_cache_bypass \\\$http_upgrade;
}
}
NGINXEOF"
sudo ln -sf $NGINX_CONF /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
echo "Nginx 已配置"
fi
echo ""
echo "===== 部署完成 ====="
echo ""
echo "后续步骤:"
echo "1. 编辑配置: sudo -u $APP_USER nano $APP_DIR/.env"
echo " - SYNC_SECRET: 从 EternalAI 数据库获取SELECT value FROM \"SystemConfig\" WHERE key='SYNC_SECRET'"
echo " - HERMES_BASE_URL: 设置为外部可访问的地址(如 https://hermes.yourdomain.com"
echo " - ETERNALAI_BASE_URL: EternalAI 服务器地址(如 https://eternalai.yourdomain.com"
echo "2. 重启服务: pm2 restart hermes-server"
echo "3. 在 EternalAI 管理后台配置 HERMES_WEBHOOK_URL 为: \${HERMES_BASE_URL}/api/sync"
echo "4. 健康检查: curl http://localhost:3002/api/health"

1335
hermes-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
{
"name": "hermes-server",
"version": "1.0.0",
"description": "Hermes Server — 接收 EternalAI 同步请求,创建 profile生成二维码绑定微信",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.0",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.2",
"qrcode": "^1.5.4"
}
}

79
hermes-server/server.js Normal file
View File

@ -0,0 +1,79 @@
// Hermes Server 入口 — 接收 EternalAI 同步请求,创建 profile生成二维码
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const { ensureDirs } = require('./src/lib/profile-store');
const app = express();
const PORT = process.env.PORT || 3002;
// 确保数据目录存在
ensureDirs();
// 中间件
// P2 修复CORS 限制为已知来源
const allowedOrigins = process.env.ALLOWED_ORIGINS;
if (allowedOrigins) {
app.use(cors({ origin: allowedOrigins.split(',').map((o) => o.trim()) }));
} else {
// 非生产环境默认允许所有来源,生产环境需配置 ALLOWED_ORIGINS
app.use(cors());
}
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// P1 修复trust proxy 仅信任 loopbackNginx 反向代理场景)
// 生产环境应配置为具体代理 IP如 app.set('trust proxy', '10.0.0.1')
app.set('trust proxy', 'loopback');
// 路由
app.use('/api', require('./src/routes/health'));
app.use('/api', require('./src/routes/sync'));
app.use('/api/profiles', require('./src/routes/profiles'));
app.use('/api/bind', require('./src/routes/bind'));
// 二维码图片静态服务
const QRCODES_DIR = path.join(__dirname, 'data', 'qrcodes');
app.use('/api/qrcodes', express.static(QRCODES_DIR, {
setHeaders: (res) => {
res.type('image/png');
res.set('Cache-Control', 'public, max-age=86400');
},
}));
// 根路径 — 简单信息页
app.get('/', (req, res) => {
res.json({
service: 'hermes-server',
status: 'running',
endpoints: {
sync: 'POST /api/sync',
profiles: 'GET /api/profiles',
bind: 'GET /api/bind/:profileId',
health: 'GET /api/health',
},
});
});
// 404
app.use((req, res) => {
res.status(404).json({ error: '接口不存在' });
});
// 错误处理
app.use((err, req, res, next) => {
console.error('[Hermes Server] 未捕获错误:', err);
res.status(500).json({ error: '服务器内部错误' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Hermes Server running on http://0.0.0.0:${PORT}`);
console.log(`HERMES_BASE_URL: ${process.env.HERMES_BASE_URL || `http://localhost:${PORT}`}`);
console.log(`ETERNALAI_BASE_URL: ${process.env.ETERNALAI_BASE_URL || '⚠️ 未配置'}`);
console.log(`SYNC_SECRET: ${process.env.SYNC_SECRET ? '已配置' : '⚠️ 未配置'}`);
console.log(`HERMES_ADMIN_TOKEN: ${process.env.HERMES_ADMIN_TOKEN ? '已配置' : '⚠️ 使用默认值'}`);
console.log(`NODE_ENV: ${process.env.NODE_ENV || 'development'}`);
});

View File

@ -0,0 +1,65 @@
// EternalAI 客户端 — 用 sync_token 回调 EternalAI 拉取 SOUL.md 和 config.yaml
// P0 修复:使用环境变量 ETERNALAI_BASE_URL不信任请求体中的 filePullBaseUrl
const FETCH_TIMEOUT_MS = 15000;
const MAX_FILE_SIZE = 1024 * 1024; // 1MB 上限
// P0 修复:从环境变量获取 EternalAI 地址,防止 SSRF + sync_token 泄露
const ETERNALAI_BASE_URL = process.env.ETERNALAI_BASE_URL || '';
// 拉取单个文件,返回文本内容
async function pullFile(baseUrl, roleId, filename, syncToken) {
const url = `${baseUrl}/api/hermes/roles/${roleId}/${filename}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: { 'X-Sync-Token': syncToken },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`拉取 ${filename} 失败: HTTP ${response.status}`);
}
// P1 修复:校验文件大小,防止 OOM
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
if (contentLength > MAX_FILE_SIZE) {
throw new Error(`${filename} 文件过大 (${contentLength} bytes),超过 ${MAX_FILE_SIZE} 上限`);
}
const text = await response.text();
// 即使没有 Content-Length 也校验实际大小
if (text.length > MAX_FILE_SIZE) {
throw new Error(`${filename} 文件过大 (${text.length} chars),超过上限`);
}
return text;
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`拉取 ${filename} 超时(${FETCH_TIMEOUT_MS}ms`);
}
throw err;
} finally {
clearTimeout(timeout);
}
}
// 拉取角色的 SOUL.md 和 config.yaml
// P0 修复:使用 ETERNALALAI_BASE_URL 环境变量,忽略请求体中的 filePullBaseUrl
async function pullRoleFiles(roleId, syncToken) {
const baseUrl = ETERNALAI_BASE_URL.replace(/\/+$/, ''); // 去掉尾部斜杠
if (!baseUrl) {
throw new Error('ETERNALAI_BASE_URL 环境变量未配置,无法拉取文件');
}
const [soulMd, configYaml] = await Promise.all([
pullFile(baseUrl, roleId, 'SOUL.md', syncToken),
pullFile(baseUrl, roleId, 'config.yaml', syncToken),
]);
return { soulMd, configYaml };
}
module.exports = { pullRoleFiles, pullFile, ETERNALAI_BASE_URL };

View File

@ -0,0 +1,188 @@
// Profile 存储 — 基于文件系统,每个 profile 一个目录
// 结构data/profiles/{profileId}/meta.json + SOUL.md + config.yaml
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const PROFILES_DIR = path.join(DATA_DIR, 'profiles');
const QRCODES_DIR = path.join(DATA_DIR, 'qrcodes');
// profileId 格式hermes_ + 24 位 hex共 31 字符)
const PROFILE_ID_RE = /^hermes_[a-f0-9]{24}$/;
// P0 修复:校验 profileId 格式,防止路径穿越
function isValidProfileId(profileId) {
return typeof profileId === 'string' && PROFILE_ID_RE.test(profileId);
}
// 确保目录存在
function ensureDirs() {
for (const dir of [DATA_DIR, PROFILES_DIR, QRCODES_DIR]) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
}
// 生成 profileIdhermes_ 前缀 + 随机 hex
function generateProfileId() {
return 'hermes_' + crypto.randomBytes(12).toString('hex');
}
// 创建 profileP1 修复:原子写入 — 先写临时目录,全部成功后 rename
function createProfile({ profileId, profileName, roleId, sourceBaseUrl, modelKey, provider, multimediaModelKey, multimediaProvider, enableSchedule, soulMd, configYaml, qrCodeUrl }) {
if (!isValidProfileId(profileId)) {
throw new Error('无效的 profileId');
}
ensureDirs();
const meta = {
profileId,
profileName,
roleId,
sourceBaseUrl,
modelKey,
provider,
multimediaModelKey,
multimediaProvider,
enableSchedule,
qrCodeUrl,
createdAt: new Date().toISOString(),
boundWechat: null,
boundAt: null,
};
// 写入临时目录,全部成功后原子 rename 到正式路径
const tmpDir = path.join(PROFILES_DIR, `.tmp_${profileId}_${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
try {
fs.writeFileSync(path.join(tmpDir, 'meta.json'), JSON.stringify(meta, null, 2));
fs.writeFileSync(path.join(tmpDir, 'SOUL.md'), soulMd);
fs.writeFileSync(path.join(tmpDir, 'config.yaml'), configYaml);
// 原子操作rename 在同一文件系统上是原子的
fs.renameSync(tmpDir, path.join(PROFILES_DIR, profileId));
} catch (err) {
// 失败时清理临时目录
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
throw err;
}
return meta;
}
// 获取 profile 元数据
function getProfileMeta(profileId) {
if (!isValidProfileId(profileId)) return null;
const metaPath = path.join(PROFILES_DIR, profileId, 'meta.json');
if (!fs.existsSync(metaPath)) return null;
return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
}
// 获取 profile 文件内容
function getProfileFile(profileId, filename) {
if (!isValidProfileId(profileId)) return null;
// filename 仅允许已知文件名
const ALLOWED_FILES = ['SOUL.md', 'config.yaml'];
if (!ALLOWED_FILES.includes(filename)) return null;
const filePath = path.join(PROFILES_DIR, profileId, filename);
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, 'utf-8');
}
// 列出所有 profileP2 修复:跳过损坏的 meta.json
function listProfiles() {
ensureDirs();
const entries = fs.readdirSync(PROFILES_DIR, { withFileTypes: true });
const profiles = [];
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.tmp_')) {
try {
const meta = getProfileMeta(entry.name);
if (meta) profiles.push(meta);
} catch (err) {
console.warn(`[Hermes] 跳过损坏的 profile: ${entry.name}`, err.message);
}
}
}
// 按创建时间倒序
profiles.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return profiles;
}
// 绑定微信P1 修复:原子检查 — 写入前再次校验未绑定)
function bindWechat(profileId, wechatId) {
if (!isValidProfileId(profileId)) return null;
const meta = getProfileMeta(profileId);
if (!meta) return null;
// 原子检查:若已绑定则拒绝(防止 TOCTOU 竞态)
if (meta.boundWechat) return { alreadyBound: true };
meta.boundWechat = wechatId;
meta.boundAt = new Date().toISOString();
// 原子写入:先写临时文件再 rename
const metaPath = path.join(PROFILES_DIR, profileId, 'meta.json');
const tmpPath = path.join(PROFILES_DIR, profileId, `.meta.json.tmp_${Date.now()}`);
fs.writeFileSync(tmpPath, JSON.stringify(meta, null, 2));
fs.renameSync(tmpPath, metaPath);
return meta;
}
// 删除 profileP0 修复:校验 profileId 防止路径穿越)
function deleteProfile(profileId) {
if (!isValidProfileId(profileId)) return false;
const dir = path.join(PROFILES_DIR, profileId);
if (!fs.existsSync(dir)) return false;
fs.rmSync(dir, { recursive: true });
// 同时清理二维码
const qrPath = path.join(QRCODES_DIR, `${profileId}.png`);
if (fs.existsSync(qrPath)) {
try { fs.unlinkSync(qrPath); } catch {}
}
return true;
}
// 保存二维码图片
function saveQrCode(profileId, pngBuffer) {
if (!isValidProfileId(profileId)) {
throw new Error('无效的 profileId');
}
ensureDirs();
const filename = `${profileId}.png`;
fs.writeFileSync(path.join(QRCODES_DIR, filename), pngBuffer);
return filename;
}
// 获取二维码图片路径
function getQrCodePath(profileId) {
if (!isValidProfileId(profileId)) return null;
const filePath = path.join(QRCODES_DIR, `${profileId}.png`);
if (!fs.existsSync(filePath)) return null;
return filePath;
}
// 根据 roleId 查找已有 profileP1 修复:幂等去重)
function findByRoleId(roleId) {
if (!roleId) return null;
const profiles = listProfiles();
return profiles.find((p) => p.roleId === roleId) || null;
}
module.exports = {
ensureDirs,
generateProfileId,
createProfile,
getProfileMeta,
getProfileFile,
listProfiles,
bindWechat,
deleteProfile,
saveQrCode,
getQrCodePath,
findByRoleId,
};

View File

@ -0,0 +1,22 @@
// 二维码生成 — 使用 qrcode 库生成 PNG 图片
const QRCode = require('qrcode');
const path = require('path');
const { saveQrCode } = require('./profile-store');
// 生成二维码并保存为 PNG 文件,返回文件名
async function generateAndSaveQrCode(profileId, bindUrl) {
const pngBuffer = await QRCode.toBuffer(bindUrl, {
type: 'png',
width: 320,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
return saveQrCode(profileId, pngBuffer);
}
module.exports = { generateAndSaveQrCode };

View File

@ -0,0 +1,58 @@
// sync_token 验证 — 使用与 EternalAI 共享的 SYNC_SECRET 验证 JWT
// sync_token 由 EternalAI 签发HS256包含 { roleId, adminId, jti, type:'sync' }5 分钟过期
const jwt = require('jsonwebtoken');
const SYNC_SECRET = process.env.SYNC_SECRET;
if (!SYNC_SECRET) {
if (process.env.NODE_ENV === 'production') {
throw new Error(
'SYNC_SECRET 环境变量未设置。请从 EternalAI 数据库获取 SYNC_SECRET 并配置到 .env 文件中。\n' +
'获取方式:在 EternalAI 服务器执行\n' +
' psql -d eternalai -c \'SELECT value FROM "SystemConfig" WHERE key=\'\'SYNC_SECRET\'\'\'\''
);
}
console.warn('[安全警告] SYNC_SECRET 未设置sync_token 验证将无法工作。请在 .env 中配置与 EternalAI 一致的 SYNC_SECRET。');
}
// P2 修复jti 重放保护 — 内存缓存已使用的 jti5 分钟 TTL
const usedJtis = new Map();
const JTI_TTL_MS = 5 * 60 * 1000;
function cleanupExpiredJtis() {
const now = Date.now();
for (const [jti, ts] of usedJtis) {
if (now - ts > JTI_TTL_MS) {
usedJtis.delete(jti);
}
}
}
// 验证 sync_token返回 payload 或 null
function verifySyncToken(token) {
if (!SYNC_SECRET) {
return null;
}
try {
// P3 修复:显式指定算法,防止 alg 混淆攻击
const decoded = jwt.verify(token, SYNC_SECRET, { algorithms: ['HS256'] });
if (decoded.type !== 'sync') return null;
// P2 修复jti 重放保护
if (decoded.jti) {
cleanupExpiredJtis();
if (usedJtis.has(decoded.jti)) {
console.warn('[安全警告] sync_token 重放被拒绝, jti:', decoded.jti);
return null;
}
usedJtis.set(decoded.jti, Date.now());
}
return decoded;
} catch {
return null;
}
}
module.exports = { verifySyncToken };

View File

@ -0,0 +1,36 @@
// Hermes 管理员认证中间件 — 使用环境变量 HERMES_ADMIN_TOKEN 进行 Bearer token 认证
const crypto = require('crypto');
const DEFAULT_TOKEN = 'dev_only_admin_token_change_me';
const ADMIN_TOKEN = process.env.HERMES_ADMIN_TOKEN;
// P1 修复:生产环境 fail-fast
if (!ADMIN_TOKEN) {
if (process.env.NODE_ENV === 'production') {
throw new Error(
'HERMES_ADMIN_TOKEN 环境变量未设置。请生成一个强随机值(可用 openssl rand -hex 32并配置到 .env 文件中。'
);
}
console.warn('[安全警告] HERMES_ADMIN_TOKEN 未设置,使用开发环境默认 token。请勿在生产环境使用。');
}
const EFFECTIVE_TOKEN = ADMIN_TOKEN || DEFAULT_TOKEN;
function adminAuthMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: '需要管理员认证' });
}
const token = authHeader.slice(7);
// P3 修复:恒定时间比较,防止时序攻击
const a = Buffer.from(token);
const b = Buffer.from(EFFECTIVE_TOKEN);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).json({ error: '管理员 token 无效' });
}
next();
}
module.exports = { adminAuthMiddleware };

View File

@ -0,0 +1,197 @@
// 绑定流程 — 二维码扫描后的绑定页面和绑定操作
// QR 码内容:{HERMES_BASE_URL}/api/bind/{profileId}
// 扫描后展示绑定页面,用户确认绑定
const express = require('express');
const { getProfileMeta, bindWechat } = require('../lib/profile-store');
const router = express.Router();
// 绑定页面GET— 扫描二维码后打开
router.get('/:profileId', (req, res) => {
try {
const meta = getProfileMeta(req.params.profileId);
if (!meta) {
// P3 修复:返回 404 状态码
return res.status(404).type('html').send(renderNotFoundPage());
}
// 如果已绑定,显示已绑定页面
if (meta.boundWechat) {
return res.type('html').send(renderAlreadyBoundPage(meta));
}
// 显示绑定确认页面
res.type('html').send(renderBindPage(meta));
} catch (err) {
// P2 修复:异常时返回 HTML 而非 JSON
console.error('绑定页面加载失败:', err);
res.status(500).type('html').send(renderNotFoundPage());
}
});
// 执行绑定POST
router.post('/:profileId', (req, res) => {
try {
const meta = getProfileMeta(req.params.profileId);
if (!meta) {
return res.status(404).json({ error: 'Profile 不存在' });
}
if (meta.boundWechat) {
return res.status(400).json({ error: '该 Profile 已绑定微信' });
}
// P2 修复req.body 可能为 undefined
const body = req.body || {};
// 绑定标识:优先用微信 OAuth code其次用请求体中的 wechatId
// 实际生产环境中,这里应通过微信 OAuth 获取 openid
const wechatId = body.wechatId || body.openid;
if (!wechatId) {
return res.status(400).json({ error: '缺少 wechatId请通过微信 OAuth 授权' });
}
// P1 修复bindWechat 内部有原子检查,处理 alreadyBound 返回
const result = bindWechat(req.params.profileId, wechatId);
if (!result) {
return res.status(404).json({ error: 'Profile 不存在' });
}
if (result.alreadyBound) {
return res.status(400).json({ error: '该 Profile 已被绑定' });
}
console.log(`[Hermes] Profile ${req.params.profileId} 已绑定微信: ${wechatId}`);
res.json({
message: '绑定成功',
profileId: result.profileId,
profileName: result.profileName,
boundAt: result.boundAt,
});
} catch (err) {
console.error('绑定失败:', err);
res.status(500).json({ error: '绑定失败' });
}
});
// ===== HTML 页面渲染 =====
function renderBindPage(meta) {
// P3 修复profileId 在 form action 中转义
const safeProfileId = encodeURIComponent(meta.profileId);
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>绑定角色 - ${escapeHtml(meta.profileName)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 400px; margin: 0 auto; padding: 24px 20px; min-height: 100vh; display: flex; flex-direction: column; justify-content: center; }
.card { background: #fff; border-radius: 16px; padding: 32px 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); text-align: center; }
.avatar { width: 72px; height: 72px; border-radius: 50%; background: linear-gradient(135deg, #667eea, #764ba2); margin: 0 auto 16px; display: flex; align-items: center; justify-content: center; font-size: 32px; color: #fff; }
h1 { font-size: 20px; margin-bottom: 8px; }
.desc { font-size: 14px; color: #888; margin-bottom: 24px; line-height: 1.6; }
.info { background: #f8f8f8; border-radius: 8px; padding: 12px 16px; margin-bottom: 24px; text-align: left; }
.info-row { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; }
.info-label { color: #999; }
.info-value { color: #333; font-weight: 500; }
.btn { width: 100%; padding: 14px; border: none; border-radius: 12px; background: #07c160; color: #fff; font-size: 16px; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
.btn:active { opacity: 0.8; }
.footer { text-align: center; margin-top: 16px; font-size: 12px; color: #bbb; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="avatar">${escapeHtml(meta.profileName.charAt(0))}</div>
<h1>${escapeHtml(meta.profileName)}</h1>
<p class="desc">扫描绑定后即可在微信中与该角色对话</p>
<div class="info">
<div class="info-row"><span class="info-label">Profile ID</span><span class="info-value">${escapeHtml(meta.profileId)}</span></div>
<div class="info-row"><span class="info-label">创建时间</span><span class="info-value">${new Date(meta.createdAt).toLocaleString('zh-CN')}</span></div>
<div class="info-row"><span class="info-label">状态</span><span class="info-value" style="color:#07c160"></span></div>
</div>
<form method="POST" action="/api/bind/${safeProfileId}">
<input type="hidden" name="wechatId" value="wechat_oauth_placeholder">
<button type="submit" class="btn">确认绑定</button>
</form>
<div class="footer">Hermes Server</div>
</div>
</div>
</body>
</html>`;
}
function renderAlreadyBoundPage(meta) {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>已绑定 - ${escapeHtml(meta.profileName)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 400px; margin: 0 auto; padding: 24px 20px; min-height: 100vh; display: flex; flex-direction: column; justify-content: center; }
.card { background: #fff; border-radius: 16px; padding: 32px 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); text-align: center; }
.icon { font-size: 48px; margin-bottom: 16px; }
h1 { font-size: 20px; margin-bottom: 8px; }
.desc { font-size: 14px; color: #888; margin-bottom: 16px; }
.info { background: #f8f8f8; border-radius: 8px; padding: 12px 16px; text-align: left; }
.info-row { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; }
.info-label { color: #999; }
.info-value { color: #333; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="icon"></div>
<h1>${escapeHtml(meta.profileName)}</h1>
<p class="desc">该角色已成功绑定微信</p>
<div class="info">
<div class="info-row"><span class="info-label">绑定时间</span><span class="info-value">${new Date(meta.boundAt).toLocaleString('zh-CN')}</span></div>
<div class="info-row"><span class="info-label">状态</span><span class="info-value" style="color:#07c160"></span></div>
</div>
</div>
</div>
</body>
</html>`;
}
function renderNotFoundPage() {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>角色不存在</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 400px; margin: 0 auto; padding: 24px; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.card { background: #fff; border-radius: 16px; padding: 32px; text-align: center; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
.icon { font-size: 48px; margin-bottom: 16px; }
h1 { font-size: 18px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="icon">🔍</div>
<h1>角色不存在或二维码已失效</h1>
</div>
</div>
</body>
</html>`;
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
module.exports = router;

View File

@ -0,0 +1,27 @@
// 健康检查
const express = require('express');
const { listProfiles } = require('../lib/profile-store');
const router = express.Router();
router.get('/health', (req, res) => {
let profileCount = 0;
let storageOk = true;
try {
profileCount = listProfiles().length;
} catch {
// P3 修复:区分"无 profile"和"读取失败"
storageOk = false;
}
// P2 修复:不泄露 syncSecretConfigured 等安全配置状态
res.json({
status: storageOk ? 'ok' : 'degraded',
service: 'hermes-server',
timestamp: new Date().toISOString(),
profileCount,
});
});
module.exports = router;

View File

@ -0,0 +1,88 @@
// Profile 管理 API — 查看、下载 profile 文件(供 Hermes Agent 和管理员使用)
const express = require('express');
const { adminAuthMiddleware } = require('../middleware/admin-auth');
const { listProfiles, getProfileMeta, getProfileFile, deleteProfile } = require('../lib/profile-store');
const router = express.Router();
// 获取所有 profile 列表(需管理员认证)
router.get('/', adminAuthMiddleware, (req, res) => {
try {
const profiles = listProfiles();
res.json({ profiles, total: profiles.length });
} catch (err) {
console.error('获取 profile 列表失败:', err);
res.status(500).json({ error: '服务器错误' });
}
});
// 获取 profile 详情(需管理员认证)
router.get('/:profileId', adminAuthMiddleware, (req, res) => {
try {
const meta = getProfileMeta(req.params.profileId);
if (!meta) {
return res.status(404).json({ error: 'Profile 不存在' });
}
res.json({ profile: meta });
} catch (err) {
console.error('获取 profile 详情失败:', err);
res.status(500).json({ error: '服务器错误' });
}
});
// 下载 SOUL.md供 Hermes Agent 使用,限制本机访问)
router.get('/:profileId/SOUL.md', (req, res) => {
try {
// P2 修复:限制本机访问
const clientIp = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
if (clientIp !== '127.0.0.1' && clientIp !== '::1' && clientIp !== '') {
return res.status(403).json({ error: '仅限本机访问' });
}
const content = getProfileFile(req.params.profileId, 'SOUL.md');
if (content === null) {
return res.status(404).json({ error: 'Profile 或 SOUL.md 不存在' });
}
// P3 修复:使用语义正确的 Content-Type
res.type('text/markdown').send(content);
} catch (err) {
console.error('获取 SOUL.md 失败:', err);
res.status(500).json({ error: '服务器错误' });
}
});
// 下载 config.yaml供 Hermes Agent 使用,限制本机访问)
router.get('/:profileId/config.yaml', (req, res) => {
try {
// P2 修复:限制本机访问
const clientIp = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
if (clientIp !== '127.0.0.1' && clientIp !== '::1' && clientIp !== '') {
return res.status(403).json({ error: '仅限本机访问' });
}
const content = getProfileFile(req.params.profileId, 'config.yaml');
if (content === null) {
return res.status(404).json({ error: 'Profile 或 config.yaml 不存在' });
}
// P3 修复:使用语义正确的 Content-Type
res.type('text/yaml').send(content);
} catch (err) {
console.error('获取 config.yaml 失败:', err);
res.status(500).json({ error: '服务器错误' });
}
});
// 删除 profile需管理员认证
router.delete('/:profileId', adminAuthMiddleware, (req, res) => {
try {
const deleted = deleteProfile(req.params.profileId);
if (!deleted) {
return res.status(404).json({ error: 'Profile 不存在' });
}
res.json({ message: 'Profile 已删除' });
} catch (err) {
console.error('删除 profile 失败:', err);
res.status(500).json({ error: '服务器错误' });
}
});
module.exports = router;

View File

@ -0,0 +1,149 @@
// POST /api/sync — 接收 EternalAI 的同步请求
// 流程:验证 sync_token → 回调拉取 SOUL.md + config.yaml → 创建/更新 profile → 生成二维码 → 返回
const express = require('express');
const { verifySyncToken } = require('../lib/sync-token-verify');
const { pullRoleFiles } = require('../lib/eternalai-client');
const { generateProfileId, createProfile, findByRoleId, deleteProfile } = require('../lib/profile-store');
const { generateAndSaveQrCode } = require('../lib/qrcode-gen');
const router = express.Router();
// P2 修复:输入校验
const MAX_PROFILE_NAME_LEN = 128;
// 来源 IP 白名单检查P1 修复:使用 socket.remoteAddress 而非可伪造的 req.ip
function checkSourceIp(req, res, next) {
const allowedIps = process.env.ALLOWED_SOURCE_IPS;
if (!allowedIps) return next();
// 使用 socket 远程地址,不受 X-Forwarded-For 影响
const clientIp = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
if (!clientIp) {
return res.status(403).json({ error: '无法确定来源 IP' });
}
const allowed = allowedIps.split(',').map((ip) => ip.trim());
if (!allowed.includes(clientIp)) {
return res.status(403).json({ error: '来源 IP 不在白名单中' });
}
next();
}
router.post('/sync', checkSourceIp, async (req, res) => {
try {
const {
profileName,
modelKey,
provider,
multimediaModelKey,
multimediaProvider,
enableSchedule,
syncToken,
roleId,
} = req.body;
// 参数校验
if (!syncToken || !roleId || !profileName) {
return res.status(400).json({ error: '缺少必要参数syncToken, roleId, profileName' });
}
// P2 修复profileName 长度限制
if (profileName.length > MAX_PROFILE_NAME_LEN) {
return res.status(400).json({ error: `profileName 过长(上限 ${MAX_PROFILE_NAME_LEN} 字符)` });
}
// 验证 sync_token
const payload = verifySyncToken(syncToken);
if (!payload) {
return res.status(401).json({ error: 'sync_token 无效或已过期' });
}
// 验证 roleId 匹配
if (payload.roleId !== roleId) {
return res.status(403).json({ error: 'roleId 与 sync_token 不匹配' });
}
// P1 修复:幂等去重 — 若该 roleId 已有 profile先删除旧的再创建新的
const existing = findByRoleId(roleId);
if (existing) {
deleteProfile(existing.profileId);
}
// 回调 EternalAI 拉取 SOUL.md 和 config.yaml
// P0 修复:使用环境变量 ETERNALAI_BASE_URL不信任请求体中的 filePullBaseUrl
const { soulMd, configYaml } = await pullRoleFiles(roleId, syncToken);
// P2 修复:校验两个文件都非空
if (!soulMd || soulMd.length === 0) {
return res.status(400).json({ error: '拉取的 SOUL.md 为空' });
}
if (!configYaml || configYaml.length === 0) {
return res.status(400).json({ error: '拉取的 config.yaml 为空' });
}
// 创建 profile
const profileId = generateProfileId();
const baseUrl = process.env.HERMES_BASE_URL || `http://localhost:${process.env.PORT || 3002}`;
const bindUrl = `${baseUrl}/api/bind/${profileId}`;
// P2 修复:先创建 profile再生成二维码避免孤儿文件
// 先用 null 作为 qrCodeUrl 创建 profile
const meta = createProfile({
profileId,
profileName,
roleId,
sourceBaseUrl: process.env.ETERNALAI_BASE_URL || 'unknown',
modelKey,
provider,
multimediaModelKey,
multimediaProvider,
enableSchedule: !!enableSchedule,
soulMd,
configYaml,
qrCodeUrl: null, // 稍后更新
});
// 生成二维码
try {
const qrFilename = await generateAndSaveQrCode(profileId, bindUrl);
const qrCodeUrl = `${baseUrl}/api/qrcodes/${qrFilename}`;
// 更新 meta 中的 qrCodeUrl通过重新写入 meta.json
const { getProfileMeta } = require('../lib/profile-store');
const updatedMeta = getProfileMeta(profileId);
if (updatedMeta) {
updatedMeta.qrCodeUrl = qrCodeUrl;
const fs = require('fs');
const path = require('path');
const metaPath = path.join(__dirname, '..', '..', 'data', 'profiles', profileId, 'meta.json');
fs.writeFileSync(metaPath, JSON.stringify(updatedMeta, null, 2));
}
meta.qrCodeUrl = qrCodeUrl;
} catch (qrErr) {
console.error('[Hermes] 二维码生成失败:', qrErr.message);
// 二维码生成失败不阻塞 profile 创建,但返回中不包含 qrCodeUrl
}
console.log(`[Hermes] Profile 创建成功: ${profileId} (name=${profileName}, roleId=${roleId})`);
res.json({
qrCodeUrl: meta.qrCodeUrl,
profileId,
message: 'Profile 创建成功',
});
} catch (err) {
console.error('[Hermes] 同步失败:', err.message);
// P3 修复:区分已知错误类型
if (err.message.includes('超时')) {
return res.status(504).json({ error: '拉取文件超时,请检查 EternalAI 服务状态' });
}
if (err.message.includes('ETERNALAI_BASE_URL')) {
return res.status(500).json({ error: 'Hermes Server 未配置 ETERNALAI_BASE_URL' });
}
if (err.message.includes('拉取') && err.message.includes('失败')) {
return res.status(502).json({ error: '拉取文件失败,请检查 EternalAI 服务是否正常' });
}
res.status(500).json({ error: '同步失败,请稍后重试' });
}
});
module.exports = router;

View File

@ -0,0 +1,356 @@
// 集成测试EternalAI → hermes-server 端到端同步流程
// 前提EternalAI (3001) 和 hermes-server (3002) 均已运行SYNC_SECRET 已同步
// 前提hermes-server 的 .env 已配置 ETERNALALAI_BASE_URL=http://localhost:3001
const BASE_ETERNAL = 'http://localhost:3001';
const BASE_HERMES = 'http://localhost:3002';
const HERMES_ADMIN_TOKEN = 'dev_only_admin_token_change_me';
let passed = 0;
let failed = 0;
async function assert(name, fn) {
try {
await fn();
passed++;
console.log(`${name}`);
} catch (err) {
failed++;
console.log(`${name}`);
console.log(` ${err.message}`);
}
}
function assertEqual(actual, expected, msg) {
if (actual !== expected) {
throw new Error(`${msg || ''} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function assertTruthy(val, msg) {
if (!val) throw new Error(msg || 'expected truthy value');
}
// 清理 hermes-server 上的所有 profile避免重复运行导致冲突
async function cleanupHermesProfiles() {
try {
const res = await fetch(`${BASE_HERMES}/api/profiles`, {
headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` },
});
if (!res.ok) return;
const data = await res.json();
for (const p of data.profiles || []) {
try {
await fetch(`${BASE_HERMES}/api/profiles/${p.profileId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` },
});
} catch {}
}
console.log(` 已清理 hermes-server 上 ${data.profiles?.length || 0} 个 profile`);
} catch (err) {
console.log(` 清理 hermes-server 跳过: ${err.message}`);
}
}
async function main() {
const prisma = require('../../src/lib/prisma');
const { hashPassword } = require('../../src/lib/auth');
// ===== 清理数据库 =====
console.log('\n===== 准备:清理数据库 =====');
await prisma.order.deleteMany();
await prisma.apiKey.deleteMany();
await prisma.role.deleteMany();
await prisma.admin.deleteMany();
await prisma.user.deleteMany();
await prisma.systemConfig.deleteMany({ where: { key: 'HERMES_WEBHOOK_URL' } });
// 确保 SYNC_SECRET 存在
const crypto = require('crypto');
await prisma.systemConfig.upsert({
where: { key: 'SYNC_SECRET' },
update: {},
create: { key: 'SYNC_SECRET', value: crypto.randomBytes(32).toString('hex') },
});
// P1 修复:清理 hermes-server 上的残留 profile避免重复运行测试失败
console.log('\n===== 准备:清理 hermes-server 数据 =====');
await cleanupHermesProfiles();
// 创建测试用户和管理员
const user = await prisma.user.create({
data: { account: 'itest_user', password: hashPassword('Test123456'), isCreator: true },
});
const admin = await prisma.admin.create({
data: { account: 'itest_admin', password: hashPassword('admin123') },
});
console.log(` 用户: ${user.id}, 管理员: ${admin.id}`);
// ===== 测试开始 =====
console.log('\n===== 测试EternalAI → hermes-server 集成 =====');
let adminToken, userToken, roleId;
// 1. 管理员登录
await assert('管理员登录', async () => {
const res = await fetch(`${BASE_ETERNAL}/api/admin-auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account: 'itest_admin', password: 'admin123' }),
});
assertEqual(res.status, 200, '管理员登录状态码');
const data = await res.json();
assertTruthy(data.token, '管理员 token');
adminToken = data.token;
});
// 2. 用户登录
await assert('用户登录', async () => {
const res = await fetch(`${BASE_ETERNAL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account: 'itest_user', password: 'Test123456' }),
});
assertEqual(res.status, 200, '用户登录状态码');
const data = await res.json();
assertTruthy(data.token, '用户 token');
userToken = data.token;
});
// 3. 用户创建角色
await assert('用户创建角色', async () => {
const res = await fetch(`${BASE_ETERNAL}/api/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userToken}` },
body: JSON.stringify({
displayName: '集成测试角色',
personality: '温柔善良',
background: '来自星辰大海',
speechStyle: '轻声细语',
greeting: '你好呀,我是星灵',
soulMd: '# SOUL\n\n我是星灵来自星辰大海的守护者。\n我温柔善良喜欢倾听你的故事。',
}),
});
assertEqual(res.status, 200, '创建角色状态码');
const data = await res.json();
assertTruthy(data.role.id, '角色 ID');
assertEqual(data.role.reviewStatus, 'pending_review', '初始审核状态');
roleId = data.role.id;
});
// 4. 管理员审核通过
await assert('管理员审核通过', async () => {
const res = await fetch(`${BASE_ETERNAL}/api/admin/reviews/${roleId}/approve`, {
method: 'POST',
headers: { Authorization: `Bearer ${adminToken}` },
});
assertEqual(res.status, 200, '审核状态码');
const data = await res.json();
assertEqual(data.role.reviewStatus, 'approved', '审核后状态');
});
// 5. 配置 Hermes webhook URL 指向真实 hermes-server
await assert('配置 HERMES_WEBHOOK_URL 指向 hermes-server', async () => {
const res = await fetch(`${BASE_ETERNAL}/api/admin/config/HERMES_WEBHOOK_URL`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` },
body: JSON.stringify({ value: `${BASE_HERMES}/api/sync` }),
});
assertEqual(res.status, 200, '配置状态码');
});
// 6. 管理员发起同步 → hermes-server 接收 → 回调拉取文件 → 创建 profile → 返回二维码
let syncResponse;
await assert('管理员发起同步(真实 hermes-server', async () => {
const res = await fetch(`${BASE_ETERNAL}/api/admin/sync/${roleId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` },
body: JSON.stringify({
profileName: 'star-spirit',
modelKey: 'sk-test-key',
provider: 'openrouter',
multimediaModelKey: 'sk-multi-key',
multimediaProvider: 'openrouter',
enableSchedule: false,
}),
});
assertEqual(res.status, 200, '同步状态码');
const data = await res.json();
assertEqual(data.role.reviewStatus, 'synced', '同步后状态');
assertTruthy(data.role.qrCodeUrl, '二维码 URL');
assertTruthy(data.profileId, 'profileId');
assertTruthy(data.profileId.startsWith('hermes_'), 'profileId 前缀');
syncResponse = data;
console.log(` profileId: ${data.profileId}`);
console.log(` qrCodeUrl: ${data.role.qrCodeUrl}`);
});
// 7. 验证 hermes-server 健康检查
await assert('hermes-server 健康检查', async () => {
const res = await fetch(`${BASE_HERMES}/api/health`);
assertEqual(res.status, 200, '健康检查状态码');
const data = await res.json();
assertEqual(data.status, 'ok', '健康状态');
// P2 修复后health 不再泄露 syncSecretConfigured改用 storageOk/status
assertEqual(data.profileCount, 1, 'profile 数量');
});
// 8. 验证 hermes-server profile 列表
await assert('hermes-server profile 列表', async () => {
const res = await fetch(`${BASE_HERMES}/api/profiles`, {
headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` },
});
assertEqual(res.status, 200, 'profile 列表状态码');
const data = await res.json();
assertEqual(data.total, 1, 'profile 总数');
assertEqual(data.profiles[0].profileName, 'star-spirit', 'profile 名称');
assertEqual(data.profiles[0].roleId, roleId, 'roleId 关联');
// P0 修复后sourceBaseUrl 来自 hermes-server 的 ETERNALAI_BASE_URL 环境变量
assertEqual(data.profiles[0].sourceBaseUrl, BASE_ETERNAL, 'sourceBaseUrl 来自环境变量');
});
// 9. 验证 hermes-server profile 详情
await assert('hermes-server profile 详情', async () => {
const res = await fetch(`${BASE_HERMES}/api/profiles/${syncResponse.profileId}`, {
headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` },
});
assertEqual(res.status, 200, 'profile 详情状态码');
const data = await res.json();
assertEqual(data.profile.profileId, syncResponse.profileId, 'profileId');
assertEqual(data.profile.modelKey, 'sk-test-key', 'modelKey');
assertEqual(data.profile.provider, 'openrouter', 'provider');
assertEqual(data.profile.enableSchedule, false, 'enableSchedule');
assertEqual(data.profile.boundWechat, null, '未绑定状态');
});
// 10. 验证 hermes-server 提供 SOUL.md 下载
await assert('hermes-server 提供 SOUL.md 下载', async () => {
const res = await fetch(`${BASE_HERMES}/api/profiles/${syncResponse.profileId}/SOUL.md`);
assertEqual(res.status, 200, 'SOUL.md 状态码');
const text = await res.text();
assertTruthy(text.includes('星灵'), 'SOUL.md 内容包含角色名');
assertTruthy(text.includes('星辰大海'), 'SOUL.md 内容包含背景');
});
// 11. 验证 hermes-server 提供 config.yaml 下载
await assert('hermes-server 提供 config.yaml 下载', async () => {
const res = await fetch(`${BASE_HERMES}/api/profiles/${syncResponse.profileId}/config.yaml`);
assertEqual(res.status, 200, 'config.yaml 状态码');
const text = await res.text();
assertTruthy(text.includes('model:'), 'config.yaml 包含 model 字段');
assertTruthy(text.includes('temperature:'), 'config.yaml 包含 temperature 字段');
});
// 12. 验证二维码图片可访问
await assert('二维码图片可访问', async () => {
const res = await fetch(syncResponse.role.qrCodeUrl);
assertEqual(res.status, 200, '二维码图片状态码');
assertEqual(res.headers.get('content-type'), 'image/png', '二维码图片类型');
const buffer = await res.arrayBuffer();
assertTruthy(buffer.byteLength > 100, '二维码图片大小');
});
// 13. 验证绑定页面可访问
await assert('绑定页面可访问', async () => {
const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`);
assertEqual(res.status, 200, '绑定页面状态码');
const html = await res.text();
assertTruthy(html.includes('star-spirit'), '绑定页面包含 profile 名称');
assertTruthy(html.includes('确认绑定'), '绑定页面包含绑定按钮');
});
// 14. 执行绑定
await assert('执行绑定', async () => {
const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wechatId: 'wx_test_12345' }),
});
assertEqual(res.status, 200, '绑定状态码');
const data = await res.json();
assertEqual(data.profileId, syncResponse.profileId, '绑定返回 profileId');
assertTruthy(data.boundAt, '绑定时间');
});
// 15. 验证已绑定后再次绑定返回 400
await assert('重复绑定返回 400', async () => {
const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wechatId: 'wx_other' }),
});
assertEqual(res.status, 400, '重复绑定状态码');
});
// 16. 验证已绑定页面显示
await assert('已绑定页面显示', async () => {
const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`);
const html = await res.text();
assertTruthy(html.includes('已成功绑定'), '已绑定页面提示');
});
// 17. 验证 EternalAI 角色库显示已同步角色
await assert('EternalAI 角色库显示已同步角色', async () => {
const res = await fetch(`${BASE_ETERNAL}/api/roles`);
const data = await res.json();
const found = data.roles.find((r) => r.id === roleId);
assertTruthy(found, '角色库包含已同步角色');
});
// 18. 验证 SYNC_SECRET 写保护(不能通过 PUT 修改)
await assert('SYNC_SECRET 写保护', async () => {
const res = await fetch(`${BASE_ETERNAL}/api/admin/config/SYNC_SECRET`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` },
body: JSON.stringify({ value: 'hacked' }),
});
assertEqual(res.status, 403, '写保护状态码');
});
// 19. 验证无效 sync_token 被拒绝
await assert('无效 sync_token 被拒绝', async () => {
const res = await fetch(`${BASE_HERMES}/api/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profileName: 'test',
syncToken: 'invalid.token.here',
roleId: 'fake-id',
}),
});
assertEqual(res.status, 401, '无效 token 状态码');
});
// 20. 验证缺少参数被拒绝
await assert('缺少参数被拒绝', async () => {
const res = await fetch(`${BASE_HERMES}/api/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileName: 'test' }),
});
assertEqual(res.status, 400, '缺少参数状态码');
});
// ===== 清理 =====
console.log('\n===== 清理 =====');
// 清理 hermes-server 上的 profile
await cleanupHermesProfiles();
// 清理 EternalAI 数据库
await prisma.order.deleteMany();
await prisma.apiKey.deleteMany();
await prisma.role.deleteMany();
await prisma.admin.deleteMany();
await prisma.user.deleteMany();
await prisma.systemConfig.deleteMany({ where: { key: 'HERMES_WEBHOOK_URL' } });
await prisma.$disconnect();
// ===== 结果 =====
console.log(`\n===== 结果: ${passed} passed, ${failed} failed =====`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error('测试运行失败:', err);
process.exit(1);
});