Scope channel settings to request profile
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { getActiveConfigPath, getActiveEnvPath, getActiveProfileName } from '../../services/hermes/hermes-profile'
|
||||
import { join } from 'path'
|
||||
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
|
||||
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
|
||||
import { restartGateway } from '../../services/hermes/hermes-cli'
|
||||
import { saveEnvValue } from '../../services/config-helpers'
|
||||
import { restartGatewayForProfile } from '../../services/hermes/gateway-autostart'
|
||||
import { saveEnvValueForProfile } from '../../services/config-helpers'
|
||||
import { logger } from '../../services/logger'
|
||||
import { safeFileStore } from '../../services/safe-file-store'
|
||||
|
||||
@@ -12,8 +13,12 @@ const PLATFORM_SECTIONS = new Set([
|
||||
'approvals',
|
||||
])
|
||||
|
||||
const configPath = () => getActiveConfigPath()
|
||||
const envPath = () => getActiveEnvPath()
|
||||
function requestedProfile(ctx: any): string {
|
||||
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
|
||||
}
|
||||
|
||||
const configPath = (profile: string) => join(getProfileDir(profile), 'config.yaml')
|
||||
const envPath = (profile: string) => join(getProfileDir(profile), '.env')
|
||||
|
||||
const envPlatformMap: Record<string, [string, string]> = {
|
||||
TELEGRAM_BOT_TOKEN: ['telegram', 'token'],
|
||||
@@ -88,9 +93,9 @@ async function destroyBridgeProfile(profile: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function readEnvPlatforms(): Promise<Record<string, any>> {
|
||||
async function readEnvPlatforms(profile: string): Promise<Record<string, any>> {
|
||||
try {
|
||||
const raw = await readFile(envPath(), 'utf-8')
|
||||
const raw = await readFile(envPath(profile), 'utf-8')
|
||||
const env = parseEnv(raw)
|
||||
const platforms: Record<string, any> = {}
|
||||
for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) {
|
||||
@@ -105,14 +110,15 @@ async function readEnvPlatforms(): Promise<Record<string, any>> {
|
||||
} catch { return {} }
|
||||
}
|
||||
|
||||
async function readConfig(): Promise<Record<string, any>> {
|
||||
return safeFileStore.readYaml(configPath())
|
||||
async function readConfig(profile: string): Promise<Record<string, any>> {
|
||||
return safeFileStore.readYaml(configPath(profile))
|
||||
}
|
||||
|
||||
export async function getConfig(ctx: any) {
|
||||
try {
|
||||
const config = await readConfig()
|
||||
const envPlatforms = await readEnvPlatforms()
|
||||
const profile = requestedProfile(ctx)
|
||||
const config = await readConfig(profile)
|
||||
const envPlatforms = await readEnvPlatforms(profile)
|
||||
if (Object.keys(envPlatforms).length > 0) {
|
||||
const existing = config.platforms || {}
|
||||
for (const [platform, vals] of Object.entries(envPlatforms)) {
|
||||
@@ -142,7 +148,8 @@ export async function updateConfig(ctx: any) {
|
||||
ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return
|
||||
}
|
||||
try {
|
||||
await safeFileStore.updateYaml(configPath(), (config) => {
|
||||
const profile = requestedProfile(ctx)
|
||||
await safeFileStore.updateYaml(configPath(profile), (config) => {
|
||||
config[section] = deepMerge(config[section] || {}, values)
|
||||
return config
|
||||
}, {
|
||||
@@ -155,11 +162,10 @@ export async function updateConfig(ctx: any) {
|
||||
// Platform adapters still run through Hermes gateway; restart it so channel
|
||||
// config changes (Feishu/Weixin/etc.) are applied, then refresh bridge sessions.
|
||||
if (restart !== false && PLATFORM_SECTIONS.has(section)) {
|
||||
const activeProfile = getActiveProfileName()
|
||||
try {
|
||||
const restartResult = await restartGateway()
|
||||
logger.info('[config] gateway restarted after config update section=%s profile=%s result=%s', section, activeProfile, restartResult)
|
||||
await destroyBridgeProfile(activeProfile)
|
||||
const restartResult = await restartGatewayForProfile(profile)
|
||||
logger.info('[config] gateway restarted after config update section=%s profile=%s result=%j', section, profile, restartResult)
|
||||
await destroyBridgeProfile(profile)
|
||||
} catch (err) {
|
||||
logger.error(err, 'Gateway restart failed')
|
||||
ctx.status = 500
|
||||
@@ -180,6 +186,7 @@ export async function updateCredentials(ctx: any) {
|
||||
ctx.status = 400; ctx.body = { error: 'Missing platform or values' }; return
|
||||
}
|
||||
try {
|
||||
const profile = requestedProfile(ctx)
|
||||
const envMap = platformEnvMap[platform]
|
||||
if (!envMap) {
|
||||
ctx.status = 400; ctx.body = { error: `Unknown platform: ${platform}` }; return
|
||||
@@ -190,12 +197,12 @@ export async function updateCredentials(ctx: any) {
|
||||
for (const [subKey, subVal] of Object.entries(val as Record<string, any>)) { flatValues[`extra.${subKey}`] = subVal }
|
||||
} else { flatValues[key] = val }
|
||||
}
|
||||
await safeFileStore.updateYaml(configPath(), async (config) => {
|
||||
await safeFileStore.updateYaml(configPath(profile), async (config) => {
|
||||
for (const [cfgPath, val] of Object.entries(flatValues)) {
|
||||
const envVar = envMap[cfgPath]
|
||||
if (!envVar) continue
|
||||
if (val === undefined || val === null || val === '') {
|
||||
await saveEnvValue(envVar, '')
|
||||
await saveEnvValueForProfile(profile, envVar, '')
|
||||
const parts = cfgPath.split('.')
|
||||
let obj: any = config.platforms?.[platform]
|
||||
if (obj) {
|
||||
@@ -209,7 +216,7 @@ export async function updateCredentials(ctx: any) {
|
||||
if (Object.keys(obj).length === 0) { if (!config.platforms) config.platforms = {}; delete config.platforms[platform] }
|
||||
}
|
||||
} else {
|
||||
await saveEnvValue(envVar, String(val))
|
||||
await saveEnvValueForProfile(profile, envVar, String(val))
|
||||
}
|
||||
}
|
||||
return config
|
||||
@@ -222,11 +229,10 @@ export async function updateCredentials(ctx: any) {
|
||||
|
||||
// Platform adapters still run through Hermes gateway; restart it so channel
|
||||
// credentials are applied, then refresh bridge sessions.
|
||||
const activeProfile = getActiveProfileName()
|
||||
try {
|
||||
const restartResult = await restartGateway()
|
||||
logger.info('[config] gateway restarted after credentials update platform=%s profile=%s result=%s', platform, activeProfile, restartResult)
|
||||
await destroyBridgeProfile(activeProfile)
|
||||
const restartResult = await restartGatewayForProfile(profile)
|
||||
logger.info('[config] gateway restarted after credentials update platform=%s profile=%s result=%j', platform, profile, restartResult)
|
||||
await destroyBridgeProfile(profile)
|
||||
} catch (err) {
|
||||
logger.error(err, 'Gateway restart failed')
|
||||
ctx.status = 500
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import axios from 'axios'
|
||||
import { chmod } from 'fs/promises'
|
||||
import { getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
||||
import { restartGateway } from '../../services/hermes/hermes-cli'
|
||||
import { safeFileStore } from '../../services/safe-file-store'
|
||||
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
|
||||
import { restartGatewayForProfile } from '../../services/hermes/gateway-autostart'
|
||||
import { saveEnvValueForProfile } from '../../services/config-helpers'
|
||||
|
||||
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
|
||||
const envPath = () => getActiveEnvPath()
|
||||
|
||||
function requestedProfile(ctx: any): string {
|
||||
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
|
||||
}
|
||||
|
||||
export async function getQrcode(ctx: any) {
|
||||
try {
|
||||
@@ -39,28 +41,13 @@ export async function save(ctx: any) {
|
||||
const { account_id, token, base_url } = ctx.request.body as { account_id: string; token: string; base_url?: string }
|
||||
if (!account_id || !token) { ctx.status = 400; ctx.body = { error: 'Missing account_id or token' }; return }
|
||||
try {
|
||||
const profile = requestedProfile(ctx)
|
||||
const entries: Record<string, string> = { WEIXIN_ACCOUNT_ID: account_id, WEIXIN_TOKEN: token }
|
||||
if (base_url) entries.WEIXIN_BASE_URL = base_url
|
||||
const ep = envPath()
|
||||
await safeFileStore.updateText(ep, (raw) => {
|
||||
const lines = raw.split('\n')
|
||||
const existingKeys = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('#')) { result.push(line); continue }
|
||||
const eqIdx = trimmed.indexOf('=')
|
||||
if (eqIdx !== -1) {
|
||||
const key = trimmed.slice(0, eqIdx).trim()
|
||||
if (key in entries) { result.push(`${key}=${entries[key]}`); existingKeys.add(key); continue }
|
||||
}
|
||||
result.push(line)
|
||||
}
|
||||
for (const [key, val] of Object.entries(entries)) { if (!existingKeys.has(key)) { result.push(`${key}=${val}`) } }
|
||||
return result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
||||
})
|
||||
try { await chmod(ep, 0o600) } catch { }
|
||||
await restartGateway()
|
||||
for (const [key, val] of Object.entries(entries)) {
|
||||
await saveEnvValueForProfile(profile, key, val)
|
||||
}
|
||||
await restartGatewayForProfile(profile)
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500; ctx.body = { error: err.message }
|
||||
|
||||
@@ -5,15 +5,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import YAML from 'js-yaml'
|
||||
|
||||
const { mockRestartGateway, mockDestroyProfile } = vi.hoisted(() => ({
|
||||
mockRestartGateway: vi.fn().mockResolvedValue('restarted'),
|
||||
mockRestartGateway: vi.fn().mockResolvedValue({ running: true, profile: 'default' }),
|
||||
mockDestroyProfile: vi.fn().mockResolvedValue({ destroyed: true }),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', async (importOriginal) => {
|
||||
const original = await importOriginal<any>()
|
||||
vi.mock('../../packages/server/src/services/hermes/gateway-autostart', () => {
|
||||
return {
|
||||
...original,
|
||||
restartGateway: mockRestartGateway,
|
||||
restartGatewayForProfile: mockRestartGateway,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,8 +31,14 @@ async function loadController() {
|
||||
return import('../../packages/server/src/controllers/hermes/config')
|
||||
}
|
||||
|
||||
function makeCtx(body: unknown): any {
|
||||
return { request: { body }, query: {}, status: 200, body: undefined }
|
||||
function makeCtx(body: unknown, profile?: string): any {
|
||||
return {
|
||||
request: { body },
|
||||
query: {},
|
||||
state: profile ? { profile: { name: profile } } : {},
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -69,7 +73,7 @@ describe('config controller locked file updates', () => {
|
||||
await updateConfig(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
expect(mockRestartGateway).toHaveBeenCalledTimes(1)
|
||||
expect(mockRestartGateway).toHaveBeenCalledWith('default')
|
||||
expect(mockDestroyProfile).toHaveBeenCalledWith('default')
|
||||
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||
expect(config.telegram.enabled).toBe(true)
|
||||
@@ -148,4 +152,57 @@ describe('config controller locked file updates', () => {
|
||||
expect(ctx.body.platforms.qqbot.allowed_users).toBe('user-1,user-2')
|
||||
expect(ctx.body.platforms.qqbot.allow_all_users).toBe(false)
|
||||
})
|
||||
|
||||
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).toHaveBeenCalledWith('research')
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
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 { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const { mockRestartGateway } = vi.hoisted(() => ({
|
||||
mockRestartGateway: vi.fn().mockResolvedValue(undefined),
|
||||
const { mockRestartGatewayForProfile } = vi.hoisted(() => ({
|
||||
mockRestartGatewayForProfile: vi.fn().mockResolvedValue({ running: true, profile: 'research' }),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
restartGateway: mockRestartGateway,
|
||||
vi.mock('../../packages/server/src/services/hermes/gateway-autostart', () => ({
|
||||
restartGatewayForProfile: mockRestartGatewayForProfile,
|
||||
}))
|
||||
|
||||
const originalHermesHome = process.env.HERMES_HOME
|
||||
const tempHomes: string[] = []
|
||||
let hermesHome = ''
|
||||
const originalHermesHome = process.env.HERMES_HOME
|
||||
|
||||
async function loadController() {
|
||||
vi.resetModules()
|
||||
@@ -21,57 +20,71 @@ async function loadController() {
|
||||
return import('../../packages/server/src/controllers/hermes/weixin')
|
||||
}
|
||||
|
||||
function makeCtx(body: unknown): any {
|
||||
return { request: { body }, status: 200, body: undefined }
|
||||
function makeCtx(body: Record<string, any>, profile = 'research'): any {
|
||||
return {
|
||||
request: { body },
|
||||
state: { profile: { name: profile } },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
hermesHome = await mkdtemp(join(tmpdir(), 'hermes-weixin-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
|
||||
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
hermesHome = ''
|
||||
})
|
||||
|
||||
describe('weixin controller save', () => {
|
||||
it('updates .env through the locked file store and preserves unrelated keys', async () => {
|
||||
describe('weixin controller', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
hermesHome = await mkdtemp(join(tmpdir(), 'hwui-weixin-controller-'))
|
||||
await mkdir(join(hermesHome, 'profiles', 'research'), { recursive: true })
|
||||
await writeFile(join(hermesHome, '.env'), [
|
||||
'OPENROUTER_API_KEY=keep',
|
||||
'WEIXIN_TOKEN=old-token',
|
||||
'WEIXIN_ACCOUNT_ID=keep-default-account',
|
||||
'WEIXIN_TOKEN=keep-default-token',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
await writeFile(join(hermesHome, 'profiles', 'research', '.env'), [
|
||||
'OPENROUTER_API_KEY=keep-research-openrouter',
|
||||
'WEIXIN_ACCOUNT_ID=old-research-account',
|
||||
'WEIXIN_TOKEN=old-research-token',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.resetModules()
|
||||
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||
else process.env.HERMES_HOME = originalHermesHome
|
||||
if (hermesHome) await rm(hermesHome, { recursive: true, force: true })
|
||||
hermesHome = ''
|
||||
})
|
||||
|
||||
it('saves scanned Weixin credentials to the request-scoped profile env only', async () => {
|
||||
const { save } = await loadController()
|
||||
const ctx = makeCtx({ account_id: 'acct-1', token: 'new-token', base_url: 'https://weixin.local' })
|
||||
const ctx = makeCtx({
|
||||
account_id: 'new-research-account',
|
||||
token: 'new-research-token',
|
||||
base_url: 'https://weixin.invalid',
|
||||
})
|
||||
|
||||
await save(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
expect(mockRestartGateway).toHaveBeenCalled()
|
||||
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
||||
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
||||
expect(env).toContain('WEIXIN_ACCOUNT_ID=acct-1')
|
||||
expect(env).toContain('WEIXIN_TOKEN=new-token')
|
||||
expect(env).toContain('WEIXIN_BASE_URL=https://weixin.local')
|
||||
expect(env).not.toContain('old-token')
|
||||
expect(mockRestartGatewayForProfile).toHaveBeenCalledWith('research')
|
||||
expect(await readFile(join(hermesHome, '.env'), 'utf-8')).toContain('WEIXIN_TOKEN=keep-default-token')
|
||||
const researchEnv = await readFile(join(hermesHome, 'profiles', 'research', '.env'), 'utf-8')
|
||||
expect(researchEnv).toContain('OPENROUTER_API_KEY=keep-research-openrouter')
|
||||
expect(researchEnv).toContain('WEIXIN_ACCOUNT_ID=new-research-account')
|
||||
expect(researchEnv).toContain('WEIXIN_TOKEN=new-research-token')
|
||||
expect(researchEnv).toContain('WEIXIN_BASE_URL=https://weixin.invalid')
|
||||
})
|
||||
|
||||
it('rejects missing required credentials without touching .env', async () => {
|
||||
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
||||
it('rejects missing required credentials without touching the profile env', async () => {
|
||||
const { save } = await loadController()
|
||||
const ctx = makeCtx({ account_id: 'acct-1' })
|
||||
const ctx = makeCtx({ account_id: 'new-research-account' })
|
||||
const envBefore = await readFile(join(hermesHome, 'profiles', 'research', '.env'), 'utf-8')
|
||||
|
||||
await save(ctx)
|
||||
|
||||
expect(ctx.status).toBe(400)
|
||||
expect(ctx.body).toEqual({ error: 'Missing account_id or token' })
|
||||
expect(mockRestartGateway).not.toHaveBeenCalled()
|
||||
await expect(readFile(join(hermesHome, '.env'), 'utf-8')).resolves.toBe('OPENROUTER_API_KEY=keep\n')
|
||||
expect(mockRestartGatewayForProfile).not.toHaveBeenCalled()
|
||||
await expect(readFile(join(hermesHome, 'profiles', 'research', '.env'), 'utf-8')).resolves.toBe(envBefore)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user