From be2089e423483b8223eee1577b3827dcc8f9b988 Mon Sep 17 00:00:00 2001 From: ekko Date: Sun, 24 May 2026 08:37:50 +0800 Subject: [PATCH] Scope channel settings to request profile --- .../server/src/controllers/hermes/config.ts | 52 +++++----- .../server/src/controllers/hermes/weixin.ts | 37 +++---- .../config-controller-file-lock.test.ts | 73 ++++++++++++-- tests/server/weixin-controller.test.ts | 99 +++++++++++-------- 4 files changed, 162 insertions(+), 99 deletions(-) diff --git a/packages/server/src/controllers/hermes/config.ts b/packages/server/src/controllers/hermes/config.ts index 9047f3d..9519aae 100644 --- a/packages/server/src/controllers/hermes/config.ts +++ b/packages/server/src/controllers/hermes/config.ts @@ -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 = { TELEGRAM_BOT_TOKEN: ['telegram', 'token'], @@ -88,9 +93,9 @@ async function destroyBridgeProfile(profile: string): Promise { } } -async function readEnvPlatforms(): Promise> { +async function readEnvPlatforms(profile: string): Promise> { try { - const raw = await readFile(envPath(), 'utf-8') + const raw = await readFile(envPath(profile), 'utf-8') const env = parseEnv(raw) const platforms: Record = {} for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) { @@ -105,14 +110,15 @@ async function readEnvPlatforms(): Promise> { } catch { return {} } } -async function readConfig(): Promise> { - return safeFileStore.readYaml(configPath()) +async function readConfig(profile: string): Promise> { + 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)) { 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 diff --git a/packages/server/src/controllers/hermes/weixin.ts b/packages/server/src/controllers/hermes/weixin.ts index 50ab367..6fbf10d 100644 --- a/packages/server/src/controllers/hermes/weixin.ts +++ b/packages/server/src/controllers/hermes/weixin.ts @@ -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 = { 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() - 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 } diff --git a/tests/server/config-controller-file-lock.test.ts b/tests/server/config-controller-file-lock.test.ts index 476b527..6333696 100644 --- a/tests/server/config-controller-file-lock.test.ts +++ b/tests/server/config-controller-file-lock.test.ts @@ -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() +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) + }) }) diff --git a/tests/server/weixin-controller.test.ts b/tests/server/weixin-controller.test.ts index a31876f..6c1336e 100644 --- a/tests/server/weixin-controller.test.ts +++ b/tests/server/weixin-controller.test.ts @@ -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, 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) }) })