Files
hermes-ui/tests/server/profile-credentials.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

338 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, mkdirSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
isExclusivePlatformKey,
stripExclusivePlatformCredentials,
disableExclusivePlatformsInConfig,
copyModelProviderAuthForClone,
EXCLUSIVE_PLATFORMS,
EXCLUSIVE_PLATFORM_ENV_PATTERNS,
} from '../../packages/server/src/services/hermes/profile-credentials'
const originalHermesHome = process.env.HERMES_HOME
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'profile-cred-test-'))
})
afterEach(() => {
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
else process.env.HERMES_HOME = originalHermesHome
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', 'FEISHU_ENCRYPT_KEY', 'FEISHU_VERIFICATION_TOKEN',
]
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',
' feishu:',
' enabled: true',
' extra:',
' app_id: app-1',
' encrypt_key: enc-1',
' verification_token: verify-1',
' discord:',
' enabled: false',
'',
].join('\n'))
const result = disableExclusivePlatformsInConfig(p)
expect(result.disabled.sort()).toEqual(['feishu', 'telegram', 'weixin'])
// 节点直挂 + extra 子节点的凭据都应该被清掉
expect(result.strippedConfigCredentials.sort()).toEqual([
'feishu.extra.app_id',
'feishu.extra.encrypt_key',
'feishu.extra.verification_token',
'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(/feishu:[\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('copyModelProviderAuthForClone', () => {
it('copies only the cloned model provider OAuth auth from the active source profile', () => {
process.env.HERMES_HOME = tmpDir
writeFileSync(join(tmpDir, 'active_profile'), 'default\n')
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
providers: {
'openai-codex': { access_token: 'codex-provider-token' },
anthropic: { access_token: 'anthropic-provider-token' },
},
credential_pool: {
'openai-codex': [{ access_token: 'codex-pool-token' }],
anthropic: [{ access_token: 'anthropic-pool-token' }],
},
}, null, 2))
const cloneDir = join(tmpDir, 'profiles', 'cloned')
mkdirSync(cloneDir, { recursive: true })
writeFileSync(join(cloneDir, 'config.yaml'), [
'model:',
' provider: openai-codex',
' default: gpt-5.5',
'',
].join('\n'))
const copied = copyModelProviderAuthForClone('cloned')
expect(copied).toEqual(['openai-codex'])
const clonedAuth = JSON.parse(readFileSync(join(cloneDir, 'auth.json'), 'utf-8'))
expect(clonedAuth.providers['openai-codex']).toEqual({ access_token: 'codex-provider-token' })
expect(clonedAuth.credential_pool['openai-codex']).toEqual([{ access_token: 'codex-pool-token' }])
expect(clonedAuth.providers.anthropic).toBeUndefined()
expect(clonedAuth.credential_pool.anthropic).toBeUndefined()
})
it('copies the Claude OAuth runtime alias needed for chat execution', () => {
process.env.HERMES_HOME = tmpDir
writeFileSync(join(tmpDir, 'active_profile'), 'default\n')
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
providers: {
'claude-oauth': { access_token: 'claude-oauth-token' },
anthropic: { access_token: 'anthropic-runtime-token' },
'openai-codex': { access_token: 'codex-provider-token' },
},
credential_pool: {
'claude-oauth': [{ access_token: 'claude-oauth-pool-token' }],
anthropic: [{ access_token: 'anthropic-runtime-pool-token' }],
'openai-codex': [{ access_token: 'codex-pool-token' }],
},
}, null, 2))
const cloneDir = join(tmpDir, 'profiles', 'cloned')
mkdirSync(cloneDir, { recursive: true })
writeFileSync(join(cloneDir, 'config.yaml'), [
'model:',
' provider: claude-oauth',
' default: claude-sonnet-4',
'',
].join('\n'))
const copied = copyModelProviderAuthForClone('cloned')
expect(copied.sort()).toEqual(['anthropic', 'claude-oauth'])
const clonedAuth = JSON.parse(readFileSync(join(cloneDir, 'auth.json'), 'utf-8'))
expect(clonedAuth.providers['claude-oauth']).toEqual({ access_token: 'claude-oauth-token' })
expect(clonedAuth.providers.anthropic).toEqual({ access_token: 'anthropic-runtime-token' })
expect(clonedAuth.credential_pool['claude-oauth']).toEqual([{ access_token: 'claude-oauth-pool-token' }])
expect(clonedAuth.credential_pool.anthropic).toEqual([{ access_token: 'anthropic-runtime-pool-token' }])
expect(clonedAuth.providers['openai-codex']).toBeUndefined()
expect(clonedAuth.credential_pool['openai-codex']).toBeUndefined()
})
it('does not copy auth for API-key providers that should use env/config credentials', () => {
process.env.HERMES_HOME = tmpDir
writeFileSync(join(tmpDir, 'active_profile'), 'default\n')
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
providers: {
anthropic: { access_token: 'anthropic-provider-token' },
},
credential_pool: {
anthropic: [{ access_token: 'anthropic-pool-token' }],
},
}, null, 2))
const cloneDir = join(tmpDir, 'profiles', 'cloned')
mkdirSync(cloneDir, { recursive: true })
writeFileSync(join(cloneDir, 'config.yaml'), [
'model:',
' provider: anthropic',
' default: claude-sonnet-4',
'',
].join('\n'))
expect(copyModelProviderAuthForClone('cloned')).toEqual([])
expect(existsSync(join(cloneDir, 'auth.json'))).toBe(false)
})
it('does not copy auth for keyless providers that are not stored OAuth providers', () => {
process.env.HERMES_HOME = tmpDir
writeFileSync(join(tmpDir, 'active_profile'), 'default\n')
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
providers: {
'fun-codex': { access_token: 'stale-fun-token' },
},
credential_pool: {
'fun-codex': [{ access_token: 'stale-fun-pool-token' }],
},
}, null, 2))
const cloneDir = join(tmpDir, 'profiles', 'cloned')
mkdirSync(cloneDir, { recursive: true })
writeFileSync(join(cloneDir, 'config.yaml'), [
'model:',
' provider: fun-codex',
' default: gpt-5.5',
'',
].join('\n'))
expect(copyModelProviderAuthForClone('cloned')).toEqual([])
expect(existsSync(join(cloneDir, 'auth.json'))).toBe(false)
})
})
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)
})
})