mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-13 20:20:46 +00:00
712 lines
26 KiB
TypeScript
712 lines
26 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import YAML from 'js-yaml'
|
|
|
|
const { mockGatewayAutostartDisabledByEnv, mockRestartGateway, mockReconcileGatewayManagement, mockDestroyProfile } = vi.hoisted(() => ({
|
|
mockGatewayAutostartDisabledByEnv: vi.fn(() => false),
|
|
mockRestartGateway: vi.fn().mockResolvedValue({ running: true, profile: 'default' }),
|
|
mockReconcileGatewayManagement: vi.fn().mockResolvedValue({
|
|
changed: false,
|
|
previousUnified: false,
|
|
nextUnified: false,
|
|
stoppedProfiles: [],
|
|
startedProfiles: [],
|
|
}),
|
|
mockDestroyProfile: vi.fn().mockResolvedValue({ destroyed: true }),
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/hermes/gateway-autostart', () => {
|
|
return {
|
|
gatewayAutostartDisabledByEnv: mockGatewayAutostartDisabledByEnv,
|
|
reconcileGatewayManagementTransition: mockReconcileGatewayManagement,
|
|
restartGatewayForProfile: mockRestartGateway,
|
|
}
|
|
})
|
|
|
|
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
|
|
AgentBridgeClient: class {
|
|
destroyProfile = mockDestroyProfile
|
|
},
|
|
}))
|
|
|
|
const originalHermesHome = process.env.HERMES_HOME
|
|
const originalWebUiHome = process.env.HERMES_WEB_UI_HOME
|
|
const tempHomes: string[] = []
|
|
let hermesHome = ''
|
|
|
|
async function loadController() {
|
|
vi.resetModules()
|
|
process.env.HERMES_HOME = hermesHome
|
|
process.env.HERMES_WEB_UI_HOME = hermesHome
|
|
return import('../../packages/server/src/controllers/hermes/config')
|
|
}
|
|
|
|
function makeCtx(body: unknown, profile?: string): any {
|
|
return {
|
|
request: { body },
|
|
query: {},
|
|
state: profile ? { profile: { name: profile } } : {},
|
|
get: vi.fn(() => ''),
|
|
status: 200,
|
|
body: undefined,
|
|
}
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks()
|
|
mockGatewayAutostartDisabledByEnv.mockReturnValue(false)
|
|
hermesHome = await mkdtemp(join(tmpdir(), 'hermes-config-controller-'))
|
|
tempHomes.push(hermesHome)
|
|
await mkdir(hermesHome, { recursive: true })
|
|
})
|
|
|
|
afterEach(async () => {
|
|
vi.resetModules()
|
|
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
|
else process.env.HERMES_HOME = originalHermesHome
|
|
if (originalWebUiHome === undefined) delete process.env.HERMES_WEB_UI_HOME
|
|
else process.env.HERMES_WEB_UI_HOME = originalWebUiHome
|
|
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
|
hermesHome = ''
|
|
})
|
|
|
|
describe('config controller locked file updates', () => {
|
|
it('deep merges a config section and restarts the gateway through hermes-cli', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'telegram:',
|
|
' enabled: false',
|
|
' extra:',
|
|
' mode: old',
|
|
'model:',
|
|
' default: glm-5.1',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
const { updateConfig } = await loadController()
|
|
const ctx = makeCtx({ section: 'telegram', values: { enabled: true, extra: { token_mode: 'env' } } })
|
|
|
|
await updateConfig(ctx)
|
|
|
|
expect(ctx.body).toEqual({ success: true })
|
|
expect(mockRestartGateway).toHaveBeenCalledWith('default')
|
|
expect(mockDestroyProfile).not.toHaveBeenCalled()
|
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
expect(config.telegram.enabled).toBe(true)
|
|
expect(config.telegram.extra).toEqual({ mode: 'old', token_mode: 'env' })
|
|
expect(config.model.default).toBe('glm-5.1')
|
|
})
|
|
|
|
it('does not auto-restart gateway for channel config when gateway auto-start is disabled', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'telegram:',
|
|
' enabled: false',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
await writeFile(join(hermesHome, 'config.json'), JSON.stringify({
|
|
gatewayAutoStart: { enabled: false },
|
|
}), 'utf-8')
|
|
const { updateConfig } = await loadController()
|
|
const ctx = makeCtx({ section: 'telegram', values: { enabled: true } })
|
|
|
|
await updateConfig(ctx)
|
|
|
|
expect(ctx.body).toEqual({ success: true })
|
|
expect(mockRestartGateway).not.toHaveBeenCalled()
|
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
expect(config.telegram.enabled).toBe(true)
|
|
})
|
|
|
|
it('does not auto-restart gateway for channel config when gateway autostart is disabled by env', async () => {
|
|
mockGatewayAutostartDisabledByEnv.mockReturnValue(true)
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'telegram:',
|
|
' enabled: false',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
const { updateConfig } = await loadController()
|
|
const ctx = makeCtx({ section: 'telegram', values: { enabled: true } })
|
|
|
|
await updateConfig(ctx)
|
|
|
|
expect(ctx.body).toEqual({ success: true })
|
|
expect(mockRestartGateway).not.toHaveBeenCalled()
|
|
})
|
|
|
|
|
|
it('reads and writes gateway auto-start policy from Web UI app config', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'model:',
|
|
' default: keep-model',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
const { updateConfig, getConfig } = await loadController()
|
|
|
|
const writeCtx = makeCtx({
|
|
section: 'gatewayAutoStart',
|
|
values: {
|
|
enabled: true,
|
|
management: 'unified',
|
|
include: ['default', ' reviewer ', '', 'default'],
|
|
exclude: ['scratch', ' missing '],
|
|
},
|
|
})
|
|
await updateConfig(writeCtx)
|
|
|
|
expect(writeCtx.body).toEqual({
|
|
success: true,
|
|
gatewayAutoStart: {
|
|
enabled: true,
|
|
management: 'unified',
|
|
include: ['default', 'reviewer'],
|
|
exclude: ['scratch', 'missing'],
|
|
},
|
|
})
|
|
expect(mockRestartGateway).not.toHaveBeenCalled()
|
|
expect(mockReconcileGatewayManagement).toHaveBeenCalledWith({
|
|
management: 'per_profile',
|
|
}, {
|
|
enabled: true,
|
|
management: 'unified',
|
|
include: ['default', 'reviewer'],
|
|
exclude: ['scratch', 'missing'],
|
|
})
|
|
|
|
const persisted = JSON.parse(await readFile(join(hermesHome, 'config.json'), 'utf-8'))
|
|
expect(persisted.gatewayAutoStart).toEqual({
|
|
enabled: true,
|
|
include: ['default', 'reviewer'],
|
|
exclude: ['scratch', 'missing'],
|
|
})
|
|
const yamlConfig = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
expect(yamlConfig.gatewayAutoStart).toBeUndefined()
|
|
expect(yamlConfig.multiplex_profiles).toBe(true)
|
|
expect(yamlConfig.gateway).toBeUndefined()
|
|
expect(yamlConfig.model.default).toBe('keep-model')
|
|
|
|
const readCtx = makeCtx({})
|
|
await getConfig(readCtx)
|
|
expect(readCtx.body.gatewayAutoStart).toEqual({
|
|
enabled: true,
|
|
management: 'unified',
|
|
include: ['default', 'reviewer'],
|
|
exclude: ['scratch', 'missing'],
|
|
})
|
|
})
|
|
|
|
it('does not reconcile gateway management when Web UI gateway auto-start is disabled', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), 'model:\n default: keep-model\n', 'utf-8')
|
|
const { updateConfig } = await loadController()
|
|
|
|
const ctx = makeCtx({
|
|
section: 'gatewayAutoStart',
|
|
values: {
|
|
enabled: false,
|
|
management: 'unified',
|
|
},
|
|
})
|
|
await updateConfig(ctx)
|
|
|
|
expect(ctx.body).toEqual({
|
|
success: true,
|
|
gatewayAutoStart: {
|
|
enabled: false,
|
|
management: 'unified',
|
|
},
|
|
})
|
|
expect(mockReconcileGatewayManagement).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not reconcile gateway management when gateway autostart is disabled by env', async () => {
|
|
mockGatewayAutostartDisabledByEnv.mockReturnValue(true)
|
|
await writeFile(join(hermesHome, 'config.yaml'), 'model:\n default: keep-model\n', 'utf-8')
|
|
const { updateConfig } = await loadController()
|
|
|
|
const ctx = makeCtx({
|
|
section: 'gatewayAutoStart',
|
|
values: {
|
|
enabled: true,
|
|
management: 'unified',
|
|
},
|
|
})
|
|
await updateConfig(ctx)
|
|
|
|
expect(ctx.body).toEqual({
|
|
success: true,
|
|
gatewayAutoStart: {
|
|
enabled: true,
|
|
management: 'unified',
|
|
},
|
|
})
|
|
expect(mockReconcileGatewayManagement).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('removes Hermes multiplex gateway config when unified gateway is disabled', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'gateway:',
|
|
' multiplex_profiles: true',
|
|
'model:',
|
|
' default: keep-model',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
const { updateConfig } = await loadController()
|
|
|
|
const ctx = makeCtx({
|
|
section: 'gatewayAutoStart',
|
|
values: {
|
|
management: 'per_profile',
|
|
},
|
|
})
|
|
await updateConfig(ctx)
|
|
|
|
expect(ctx.body.gatewayAutoStart.management).toBe('per_profile')
|
|
const yamlConfig = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
expect(yamlConfig.gateway).toBeUndefined()
|
|
expect(yamlConfig.multiplex_profiles).toBeUndefined()
|
|
expect(yamlConfig.model.default).toBe('keep-model')
|
|
})
|
|
|
|
it('clears credential env values and removes matching config fields without losing unrelated env keys', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'platforms:',
|
|
' weixin:',
|
|
' token: old-token',
|
|
' extra:',
|
|
' account_id: old-account',
|
|
' base_url: https://old.example',
|
|
'model:',
|
|
' default: glm-5.1',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
await writeFile(join(hermesHome, '.env'), [
|
|
'OPENROUTER_API_KEY=keep',
|
|
'WEIXIN_TOKEN=old-token',
|
|
'WEIXIN_ACCOUNT_ID=old-account',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
const { updateCredentials } = await loadController()
|
|
const ctx = makeCtx({ platform: 'weixin', values: { token: '', extra: { account_id: '', base_url: 'https://new.example' } } })
|
|
|
|
await updateCredentials(ctx)
|
|
|
|
expect(ctx.body).toEqual({ success: true })
|
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
|
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
|
expect(env).not.toContain('WEIXIN_TOKEN=')
|
|
expect(env).not.toContain('WEIXIN_ACCOUNT_ID=')
|
|
expect(env).toContain('WEIXIN_BASE_URL=https://new.example')
|
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
expect(config.platforms.weixin.token).toBeUndefined()
|
|
expect(config.platforms.weixin.extra.account_id).toBeUndefined()
|
|
expect(config.platforms.weixin.extra.base_url).toBe('https://old.example')
|
|
expect(config.model.default).toBe('glm-5.1')
|
|
})
|
|
|
|
it('does not auto-restart gateway after credential updates when gateway auto-start is disabled', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'platforms:',
|
|
' weixin:',
|
|
' token: old-token',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
await writeFile(join(hermesHome, 'config.json'), JSON.stringify({
|
|
gatewayAutoStart: { enabled: false },
|
|
}), 'utf-8')
|
|
const { updateCredentials } = await loadController()
|
|
const ctx = makeCtx({ platform: 'weixin', values: { token: 'new-token' } })
|
|
|
|
await updateCredentials(ctx)
|
|
|
|
expect(ctx.body).toEqual({ success: true })
|
|
expect(mockRestartGateway).not.toHaveBeenCalled()
|
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
|
expect(env).toContain('WEIXIN_TOKEN=new-token')
|
|
})
|
|
|
|
it('does not auto-restart gateway after credential updates when gateway autostart is disabled by env', async () => {
|
|
mockGatewayAutostartDisabledByEnv.mockReturnValue(true)
|
|
await writeFile(join(hermesHome, 'config.yaml'), 'platforms: {}\n', 'utf-8')
|
|
const { updateCredentials } = await loadController()
|
|
const ctx = makeCtx({ platform: 'weixin', values: { token: 'new-token' } })
|
|
|
|
await updateCredentials(ctx)
|
|
|
|
expect(ctx.body).toEqual({ success: true })
|
|
expect(mockRestartGateway).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('writes QQBot credentials to env and overlays them into platform config reads', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'platforms:',
|
|
' qqbot:',
|
|
' extra:',
|
|
' markdown_support: true',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
|
const { updateCredentials, getConfig } = await loadController()
|
|
|
|
await updateCredentials(makeCtx({
|
|
platform: 'qqbot',
|
|
values: {
|
|
extra: { app_id: 'qq-app', client_secret: 'qq-secret' },
|
|
allowed_users: 'user-1,user-2',
|
|
allow_all_users: false,
|
|
},
|
|
}))
|
|
|
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
|
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
|
expect(env).toContain('QQ_APP_ID=qq-app')
|
|
expect(env).toContain('QQ_CLIENT_SECRET=qq-secret')
|
|
expect(env).toContain('QQ_ALLOWED_USERS=user-1,user-2')
|
|
expect(env).toContain('QQ_ALLOW_ALL_USERS=false')
|
|
|
|
const ctx = makeCtx({})
|
|
await getConfig(ctx)
|
|
expect(ctx.body.platforms.qqbot.extra.app_id).toBe('qq-app')
|
|
expect(ctx.body.platforms.qqbot.extra.client_secret).toBe('qq-secret')
|
|
expect(ctx.body.platforms.qqbot.extra.markdown_support).toBe(true)
|
|
expect(ctx.body.platforms.qqbot.allowed_users).toBe('user-1,user-2')
|
|
expect(ctx.body.platforms.qqbot.allow_all_users).toBe(false)
|
|
})
|
|
|
|
it('round-trips Feishu webhook credentials through env-backed platform settings', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'platforms:',
|
|
' feishu:',
|
|
' extra:',
|
|
' mode: webhook',
|
|
' app_id: old-config-app',
|
|
' encrypt_key: old-config-encrypt',
|
|
' verification_token: old-config-verify',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
|
const { updateCredentials, getConfig } = await loadController()
|
|
|
|
await updateCredentials(makeCtx({
|
|
platform: 'feishu',
|
|
values: {
|
|
extra: {
|
|
app_id: 'cli_test_app',
|
|
app_secret: 'feishu-secret',
|
|
encrypt_key: 'feishu-encrypt',
|
|
verification_token: 'feishu-verify',
|
|
},
|
|
},
|
|
}))
|
|
|
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
|
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
|
expect(env).toContain('FEISHU_APP_ID=cli_test_app')
|
|
expect(env).toContain('FEISHU_APP_SECRET=feishu-secret')
|
|
expect(env).toContain('FEISHU_ENCRYPT_KEY=feishu-encrypt')
|
|
expect(env).toContain('FEISHU_VERIFICATION_TOKEN=feishu-verify')
|
|
|
|
const migratedConfig = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
expect(migratedConfig.platforms.feishu.extra.mode).toBe('webhook')
|
|
expect(migratedConfig.platforms.feishu.extra.app_id).toBeUndefined()
|
|
expect(migratedConfig.platforms.feishu.extra.app_secret).toBeUndefined()
|
|
expect(migratedConfig.platforms.feishu.extra.encrypt_key).toBeUndefined()
|
|
expect(migratedConfig.platforms.feishu.extra.verification_token).toBeUndefined()
|
|
|
|
const readCtx = makeCtx({})
|
|
await getConfig(readCtx)
|
|
expect(readCtx.body.platforms.feishu.extra.mode).toBe('webhook')
|
|
expect(readCtx.body.platforms.feishu.extra.app_id).toBe('cli_test_app')
|
|
expect(readCtx.body.platforms.feishu.extra.app_secret).toBe('feishu-secret')
|
|
expect(readCtx.body.platforms.feishu.extra.encrypt_key).toBe('feishu-encrypt')
|
|
expect(readCtx.body.platforms.feishu.extra.verification_token).toBe('feishu-verify')
|
|
|
|
await updateCredentials(makeCtx({
|
|
platform: 'feishu',
|
|
values: {
|
|
extra: {
|
|
app_id: '',
|
|
app_secret: '',
|
|
encrypt_key: '',
|
|
verification_token: '',
|
|
},
|
|
},
|
|
}))
|
|
|
|
const clearedEnv = await readFile(join(hermesHome, '.env'), 'utf-8')
|
|
expect(clearedEnv).toContain('OPENROUTER_API_KEY=keep')
|
|
expect(clearedEnv).not.toContain('FEISHU_APP_ID=')
|
|
expect(clearedEnv).not.toContain('FEISHU_APP_SECRET=')
|
|
expect(clearedEnv).not.toContain('FEISHU_ENCRYPT_KEY=')
|
|
expect(clearedEnv).not.toContain('FEISHU_VERIFICATION_TOKEN=')
|
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
expect(config.platforms.feishu.extra.mode).toBe('webhook')
|
|
expect(config.platforms.feishu.extra.app_id).toBeUndefined()
|
|
expect(config.platforms.feishu.extra.app_secret).toBeUndefined()
|
|
expect(config.platforms.feishu.extra.encrypt_key).toBeUndefined()
|
|
expect(config.platforms.feishu.extra.verification_token).toBeUndefined()
|
|
})
|
|
|
|
it('round-trips Matrix password credentials through env-backed platform settings', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'platforms:',
|
|
' matrix:',
|
|
' extra:',
|
|
' homeserver: https://old.example.org',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
|
const { updateCredentials, getConfig } = await loadController()
|
|
|
|
await updateCredentials(makeCtx({
|
|
platform: 'matrix',
|
|
values: {
|
|
extra: {
|
|
homeserver: 'https://matrix.example.org',
|
|
user_id: '@hermes:example.org',
|
|
password: 'matrix-secret',
|
|
},
|
|
},
|
|
}))
|
|
|
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
|
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
|
expect(env).toContain('MATRIX_HOMESERVER=https://matrix.example.org')
|
|
expect(env).toContain('MATRIX_USER_ID=@hermes:example.org')
|
|
expect(env).toContain('MATRIX_PASSWORD=matrix-secret')
|
|
|
|
const readCtx = makeCtx({})
|
|
await getConfig(readCtx)
|
|
expect(readCtx.body.platforms.matrix.extra.homeserver).toBe('https://matrix.example.org')
|
|
expect(readCtx.body.platforms.matrix.extra.user_id).toBe('@hermes:example.org')
|
|
expect(readCtx.body.platforms.matrix.extra.password).toBe('matrix-secret')
|
|
})
|
|
|
|
it('round-trips platform proxy URLs through env-backed channel settings', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), 'platforms: {}\n', 'utf-8')
|
|
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
|
const { updateCredentials, getConfig } = await loadController()
|
|
|
|
await updateCredentials(makeCtx({
|
|
platform: 'telegram',
|
|
values: { proxy: 'socks5://127.0.0.1:7890' },
|
|
}))
|
|
await updateCredentials(makeCtx({
|
|
platform: 'discord',
|
|
values: { proxy: 'http://127.0.0.1:8080' },
|
|
}))
|
|
await updateCredentials(makeCtx({
|
|
platform: 'matrix',
|
|
values: { proxy: 'https://proxy.example:8443' },
|
|
}))
|
|
|
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
|
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
|
expect(env).toContain('TELEGRAM_PROXY=socks5://127.0.0.1:7890')
|
|
expect(env).toContain('DISCORD_PROXY=http://127.0.0.1:8080')
|
|
expect(env).toContain('MATRIX_PROXY=https://proxy.example:8443')
|
|
|
|
const readCtx = makeCtx({})
|
|
await getConfig(readCtx)
|
|
expect(readCtx.body.platforms.telegram.proxy).toBe('socks5://127.0.0.1:7890')
|
|
expect(readCtx.body.platforms.discord.proxy).toBe('http://127.0.0.1:8080')
|
|
expect(readCtx.body.platforms.matrix.proxy).toBe('https://proxy.example:8443')
|
|
|
|
await updateCredentials(makeCtx({
|
|
platform: 'discord',
|
|
values: { proxy: '' },
|
|
}))
|
|
const clearedEnv = await readFile(join(hermesHome, '.env'), 'utf-8')
|
|
expect(clearedEnv).not.toContain('DISCORD_PROXY=')
|
|
})
|
|
|
|
it('round-trips global proxy env settings and restarts the gateway', async () => {
|
|
await writeFile(join(hermesHome, 'config.yaml'), 'model:\n default: glm-5.1\n', 'utf-8')
|
|
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\nHTTP_PROXY=http://old.example:8080\n', 'utf-8')
|
|
const { updateConfig, getConfig } = await loadController()
|
|
|
|
await updateConfig(makeCtx({
|
|
section: 'proxy',
|
|
values: {
|
|
HTTPS_PROXY: 'http://127.0.0.1:7890',
|
|
HTTP_PROXY: '',
|
|
ALL_PROXY: 'socks5://127.0.0.1:7891',
|
|
NO_PROXY: 'localhost,127.0.0.1,.local',
|
|
},
|
|
}))
|
|
|
|
expect(mockRestartGateway).toHaveBeenCalledWith('default')
|
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
|
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
|
expect(env).toContain('HTTPS_PROXY=http://127.0.0.1:7890')
|
|
expect(env).not.toContain('HTTP_PROXY=')
|
|
expect(env).toContain('ALL_PROXY=socks5://127.0.0.1:7891')
|
|
expect(env).toContain('NO_PROXY=localhost,127.0.0.1,.local')
|
|
|
|
const ctx = makeCtx({})
|
|
await getConfig(ctx)
|
|
expect(ctx.body.proxy).toEqual({
|
|
HTTPS_PROXY: 'http://127.0.0.1:7890',
|
|
ALL_PROXY: 'socks5://127.0.0.1:7891',
|
|
NO_PROXY: 'localhost,127.0.0.1,.local',
|
|
})
|
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
expect(config.proxy).toBeUndefined()
|
|
expect(config.model.default).toBe('glm-5.1')
|
|
})
|
|
|
|
it('reads and writes channel settings in the request-scoped profile only', async () => {
|
|
const researchDir = join(hermesHome, 'profiles', 'research')
|
|
await mkdir(researchDir, { recursive: true })
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'telegram:',
|
|
' require_mention: false',
|
|
'model:',
|
|
' default: keep-default-model',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
await writeFile(join(hermesHome, '.env'), [
|
|
'TELEGRAM_BOT_TOKEN=keep-default-token',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
await writeFile(join(researchDir, 'config.yaml'), [
|
|
'telegram:',
|
|
' require_mention: false',
|
|
'model:',
|
|
' default: research-model',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
await writeFile(join(researchDir, '.env'), [
|
|
'TELEGRAM_BOT_TOKEN=old-research-token',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
|
|
const { updateConfig, updateCredentials, getConfig } = await loadController()
|
|
|
|
await updateConfig(makeCtx({
|
|
section: 'telegram',
|
|
values: { require_mention: true, free_response_chats: 'chat-1' },
|
|
}, 'research'))
|
|
await updateCredentials(makeCtx({
|
|
platform: 'telegram',
|
|
values: { token: 'new-research-token' },
|
|
}, 'research'))
|
|
|
|
expect(mockRestartGateway).toHaveBeenCalledWith('research')
|
|
expect(mockDestroyProfile).not.toHaveBeenCalled()
|
|
const defaultConfig = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
const researchConfig = YAML.load(await readFile(join(researchDir, 'config.yaml'), 'utf-8')) as any
|
|
expect(defaultConfig.telegram.require_mention).toBe(false)
|
|
expect(researchConfig.telegram.require_mention).toBe(true)
|
|
expect(researchConfig.telegram.free_response_chats).toBe('chat-1')
|
|
expect(await readFile(join(hermesHome, '.env'), 'utf-8')).toContain('TELEGRAM_BOT_TOKEN=keep-default-token')
|
|
expect(await readFile(join(researchDir, '.env'), 'utf-8')).toContain('TELEGRAM_BOT_TOKEN=new-research-token')
|
|
|
|
const ctx = makeCtx({}, 'research')
|
|
await getConfig(ctx)
|
|
expect(ctx.body.platforms.telegram.token).toBe('new-research-token')
|
|
expect(ctx.body.telegram.require_mention).toBe(true)
|
|
})
|
|
|
|
it('reads and replaces auxiliary model settings in the requested profile', async () => {
|
|
const researchDir = join(hermesHome, 'profiles', 'research')
|
|
await mkdir(researchDir, { recursive: true })
|
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
|
'model:',
|
|
' default: root-model',
|
|
'auxiliary:',
|
|
' compression:',
|
|
' provider: openrouter',
|
|
' model: root-compressor',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
await writeFile(join(researchDir, 'config.yaml'), [
|
|
'model:',
|
|
' default: research-model',
|
|
'auxiliary:',
|
|
' vision:',
|
|
' provider: main',
|
|
' web_extract:',
|
|
' provider: auto',
|
|
' base_url: keep-visible-base-url',
|
|
' api_key: keep-visible-api-key',
|
|
'',
|
|
].join('\n'), 'utf-8')
|
|
|
|
const { getAuxiliaryModels, updateAuxiliaryModels } = await loadController()
|
|
const readCtx = makeCtx({})
|
|
readCtx.get = vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'research' : '')
|
|
|
|
await getAuxiliaryModels(readCtx)
|
|
|
|
expect(readCtx.body.auxiliary).toEqual({
|
|
vision: { provider: 'main' },
|
|
web_extract: {
|
|
provider: 'auto',
|
|
base_url: 'keep-visible-base-url',
|
|
api_key: 'keep-visible-api-key',
|
|
},
|
|
})
|
|
expect(readCtx.body.tasks.some((task: any) => task.key === 'compression' && task.default_timeout === 120)).toBe(true)
|
|
expect(readCtx.body.tasks.some((task: any) => task.key === 'vision' && task.default_download_timeout === 30)).toBe(true)
|
|
|
|
const writeCtx = makeCtx({
|
|
auxiliary: {
|
|
compression: {
|
|
provider: ' openrouter ',
|
|
model: ' google/gemini-3-flash-preview ',
|
|
timeout: 120.7,
|
|
download_timeout: 30,
|
|
extra_body: { temperature: 0 },
|
|
ignored: 'drop',
|
|
},
|
|
empty_task: {
|
|
provider: 'auto',
|
|
model: 'drop-model',
|
|
base_url: 'drop-base-url',
|
|
api_key: 'drop-api-key',
|
|
extra_body: { should: 'drop' },
|
|
timeout: 30,
|
|
},
|
|
blank_task: {
|
|
provider: '',
|
|
model: '',
|
|
},
|
|
},
|
|
})
|
|
writeCtx.get = vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'research' : '')
|
|
|
|
await updateAuxiliaryModels(writeCtx)
|
|
|
|
expect(writeCtx.body).toEqual({
|
|
success: true,
|
|
auxiliary: {
|
|
compression: {
|
|
provider: 'openrouter',
|
|
model: 'google/gemini-3-flash-preview',
|
|
timeout: 120,
|
|
extra_body: { temperature: 0 },
|
|
},
|
|
empty_task: {
|
|
provider: 'auto',
|
|
timeout: 30,
|
|
},
|
|
},
|
|
})
|
|
const rootConfig = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
|
const researchConfig = YAML.load(await readFile(join(researchDir, 'config.yaml'), 'utf-8')) as any
|
|
expect(rootConfig.auxiliary.compression.model).toBe('root-compressor')
|
|
expect(researchConfig.model.default).toBe('research-model')
|
|
expect(researchConfig.auxiliary).toEqual({
|
|
compression: {
|
|
provider: 'openrouter',
|
|
model: 'google/gemini-3-flash-preview',
|
|
timeout: 120,
|
|
extra_body: { temperature: 0 },
|
|
},
|
|
empty_task: {
|
|
provider: 'auto',
|
|
timeout: 30,
|
|
},
|
|
})
|
|
})
|
|
})
|