diff --git a/packages/server/src/controllers/hermes/config.ts b/packages/server/src/controllers/hermes/config.ts index 2b7a1f2..b6a24f9 100644 --- a/packages/server/src/controllers/hermes/config.ts +++ b/packages/server/src/controllers/hermes/config.ts @@ -1,9 +1,9 @@ -import { readFile, writeFile, copyFile } from 'fs/promises' -import YAML from 'js-yaml' +import { readFile } from 'fs/promises' import { getGatewayManagerInstance } from '../../services/gateway-bootstrap' import { getActiveConfigPath, getActiveEnvPath } from '../../services/hermes/hermes-profile' import { saveEnvValue } from '../../services/config-helpers' import { logger } from '../../services/logger' +import { safeFileStore } from '../../services/safe-file-store' const PLATFORM_SECTIONS = new Set([ 'telegram', 'discord', 'slack', 'whatsapp', 'matrix', @@ -90,20 +90,7 @@ async function readEnvPlatforms(): Promise> { } async function readConfig(): Promise> { - const raw = await readFile(configPath(), 'utf-8') - return (YAML.load(raw, { json: true }) as Record) || {} -} - -async function writeConfig(data: Record): Promise { - const cp = configPath() - await copyFile(cp, cp + '.bak') - const yamlStr = YAML.dump(data, { - lineWidth: -1, - noRefs: true, - quotingType: '"', - forceQuotes: true, // Force quotes on all string values - }) - await writeFile(cp, yamlStr, 'utf-8') + return safeFileStore.readYaml(configPath()) } export async function getConfig(ctx: any) { @@ -139,9 +126,15 @@ export async function updateConfig(ctx: any) { ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return } try { - const config = await readConfig() - config[section] = deepMerge(config[section] || {}, values) - await writeConfig(config) + await safeFileStore.updateYaml(configPath(), (config) => { + config[section] = deepMerge(config[section] || {}, values) + return config + }, { + backup: true, + dumpOptions: { + forceQuotes: true, + }, + }) // 使用 GatewayManager 重启平台网关 if (PLATFORM_SECTIONS.has(section)) { @@ -173,37 +166,41 @@ export async function updateCredentials(ctx: any) { if (!envMap) { ctx.status = 400; ctx.body = { error: `Unknown platform: ${platform}` }; return } - const config = await readConfig() - let configChanged = false const flatValues: Record = {} for (const [key, val] of Object.entries(values)) { if (key === 'extra' && val && typeof val === 'object') { for (const [subKey, subVal] of Object.entries(val as Record)) { flatValues[`extra.${subKey}`] = subVal } } else { flatValues[key] = val } } - for (const [cfgPath, val] of Object.entries(flatValues)) { - const envVar = envMap[cfgPath] - if (!envVar) continue - if (val === undefined || val === null || val === '') { - await saveEnvValue(envVar, '') - const parts = cfgPath.split('.') - let obj: any = config.platforms?.[platform] - if (obj) { - if (parts.length === 1) { delete obj[parts[0]] } - else { - let cur = obj - for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) break; cur = cur[parts[i]] } - delete cur[parts[parts.length - 1]] - if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra + await safeFileStore.updateYaml(configPath(), 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, '') + const parts = cfgPath.split('.') + let obj: any = config.platforms?.[platform] + if (obj) { + if (parts.length === 1) { delete obj[parts[0]] } + else { + let cur = obj + for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) break; cur = cur[parts[i]] } + delete cur[parts[parts.length - 1]] + if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra + } + if (Object.keys(obj).length === 0) { if (!config.platforms) config.platforms = {}; delete config.platforms[platform] } } - if (Object.keys(obj).length === 0) { if (!config.platforms) config.platforms = {}; delete config.platforms[platform] } - configChanged = true + } else { + await saveEnvValue(envVar, String(val)) } - } else { - await saveEnvValue(envVar, String(val)) } - } - if (configChanged) { await writeConfig(config) } + return config + }, { + backup: true, + dumpOptions: { + forceQuotes: true, + }, + }) // 使用 GatewayManager 重启平台网关 const mgr = getGatewayManagerInstance() diff --git a/packages/server/src/controllers/hermes/copilot-auth.ts b/packages/server/src/controllers/hermes/copilot-auth.ts index dc3c107..168a2bf 100644 --- a/packages/server/src/controllers/hermes/copilot-auth.ts +++ b/packages/server/src/controllers/hermes/copilot-auth.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto' import { startDeviceFlow, pollDeviceFlow } from '../../services/hermes/copilot-device-flow' -import { saveEnvValue, readConfigYaml, writeConfigYaml } from '../../services/config-helpers' +import { saveEnvValue, updateConfigYaml } from '../../services/config-helpers' import { invalidateAllCaches, resolveCopilotOAuthTokenWithSource, @@ -192,16 +192,17 @@ export async function disable(ctx: any): Promise { // 不能 swallow —— 否则会出现 "list 已隐藏 copilot 但 default 仍是 copilot" 的中间态。 let clearedDefault = false try { - const cfg = await readConfigYaml() - const modelSection = cfg.model - if (typeof modelSection === 'object' && modelSection !== null) { - const provider = String(modelSection.provider || '').trim().toLowerCase() - if (provider === 'copilot') { - cfg.model = {} - await writeConfigYaml(cfg) - clearedDefault = true + clearedDefault = await updateConfigYaml((cfg) => { + const modelSection = cfg.model + if (typeof modelSection === 'object' && modelSection !== null) { + const provider = String(modelSection.provider || '').trim().toLowerCase() + if (provider === 'copilot') { + cfg.model = {} + return { data: cfg, result: true } + } } - } + return { data: cfg, result: false, write: false } + }) || false } catch (err: any) { logger.error(err, 'Copilot disable failed: cannot clear default model') ctx.status = 500 diff --git a/packages/server/src/controllers/hermes/models.ts b/packages/server/src/controllers/hermes/models.ts index 3d9e59e..ce406a1 100644 --- a/packages/server/src/controllers/hermes/models.ts +++ b/packages/server/src/controllers/hermes/models.ts @@ -1,7 +1,7 @@ import { readFile } from 'fs/promises' import { existsSync, readFileSync } from 'fs' import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile' -import { readConfigYaml, writeConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers' +import { readConfigYaml, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers' import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers' import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models' import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config' @@ -483,11 +483,12 @@ export async function setConfigModel(ctx: any) { return } try { - const config = await readConfigYaml() - config.model = {} - config.model.default = defaultModel - if (reqProvider) { config.model.provider = reqProvider } - await writeConfigYaml(config) + await updateConfigYaml((config) => { + config.model = {} + config.model.default = defaultModel + if (reqProvider) { config.model.provider = reqProvider } + return config + }) ctx.body = { success: true } } catch (err: any) { ctx.status = 500 diff --git a/packages/server/src/controllers/hermes/providers.ts b/packages/server/src/controllers/hermes/providers.ts index e08fba7..9f1319a 100644 --- a/packages/server/src/controllers/hermes/providers.ts +++ b/packages/server/src/controllers/hermes/providers.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'fs' import { writeFile } from 'fs/promises' import { getActiveAuthPath } from '../../services/hermes/hermes-profile' import * as hermesCli from '../../services/hermes/hermes-cli' -import { readConfigYaml, writeConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services/config-helpers' +import { updateConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services/config-helpers' import { PROVIDER_PRESETS } from '../../shared/providers' import { logger } from '../../services/logger' @@ -50,48 +50,18 @@ export async function create(ctx: any) { try { const poolKey = providerKey || `custom:${name.trim().toLowerCase().replace(/ /g, '-')}` const isBuiltin = poolKey in PROVIDER_ENV_MAP - const config = await readConfigYaml() - if (typeof config.model !== 'object' || config.model === null) { config.model = {} } - if (!isBuiltin) { - if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] } - const existing = (config.custom_providers as any[]).find( - (e: any) => `custom:${e.name}` === poolKey - ) - if (existing) { - existing.base_url = base_url - existing.api_key = api_key - existing.model = model - const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', '')) - if (preset?.api_mode) existing.api_mode = preset.api_mode - if (context_length && context_length > 0) { - if (!existing.models) existing.models = {} - existing.models[model] = existing.models[model] || {} - existing.models[model].context_length = context_length - } - } else { - const entry = buildProviderEntry(name.trim().toLowerCase().replace(/ /g, '-'), base_url, api_key, model, context_length) - const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', '')) - if (preset?.api_mode) entry.api_mode = preset.api_mode - config.custom_providers.push(entry) - } - config.model.default = model - config.model.provider = poolKey - } else { - if (PROVIDER_ENV_MAP[poolKey].api_key_env) { - await saveEnvValue(PROVIDER_ENV_MAP[poolKey].api_key_env, api_key) - if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValue(PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) } - config.model.default = model - config.model.provider = poolKey - } else { + await updateConfigYaml(async (config) => { + if (typeof config.model !== 'object' || config.model === null) { config.model = {} } + if (!isBuiltin) { if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] } const existing = (config.custom_providers as any[]).find( - (e: any) => `custom:${e.name}` === `custom:${poolKey}` + (e: any) => `custom:${e.name}` === poolKey ) if (existing) { existing.base_url = base_url existing.api_key = api_key existing.model = model - const preset = PROVIDER_PRESETS.find(p => p.value === poolKey) + const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', '')) if (preset?.api_mode) existing.api_mode = preset.api_mode if (context_length && context_length > 0) { if (!existing.models) existing.models = {} @@ -99,18 +69,49 @@ export async function create(ctx: any) { existing.models[model].context_length = context_length } } else { - const entry = buildProviderEntry(poolKey, base_url, api_key, model, context_length) - const preset = PROVIDER_PRESETS.find(p => p.value === poolKey) + const entry = buildProviderEntry(name.trim().toLowerCase().replace(/ /g, '-'), base_url, api_key, model, context_length) + const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', '')) if (preset?.api_mode) entry.api_mode = preset.api_mode config.custom_providers.push(entry) } config.model.default = model - config.model.provider = `custom:${poolKey}` + config.model.provider = poolKey + } else { + if (PROVIDER_ENV_MAP[poolKey].api_key_env) { + await saveEnvValue(PROVIDER_ENV_MAP[poolKey].api_key_env, api_key) + if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValue(PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) } + config.model.default = model + config.model.provider = poolKey + } else { + if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] } + const existing = (config.custom_providers as any[]).find( + (e: any) => `custom:${e.name}` === `custom:${poolKey}` + ) + if (existing) { + existing.base_url = base_url + existing.api_key = api_key + existing.model = model + const preset = PROVIDER_PRESETS.find(p => p.value === poolKey) + if (preset?.api_mode) existing.api_mode = preset.api_mode + if (context_length && context_length > 0) { + if (!existing.models) existing.models = {} + existing.models[model] = existing.models[model] || {} + existing.models[model].context_length = context_length + } + } else { + const entry = buildProviderEntry(poolKey, base_url, api_key, model, context_length) + const preset = PROVIDER_PRESETS.find(p => p.value === poolKey) + if (preset?.api_mode) entry.api_mode = preset.api_mode + config.custom_providers.push(entry) + } + config.model.default = model + config.model.provider = `custom:${poolKey}` + } } - } - delete config.model.base_url - delete config.model.api_key - await writeConfigYaml(config) + delete config.model.base_url + delete config.model.api_key + return config + }) // TODO: Test if provider works without gateway restart // try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') } ctx.body = { success: true } @@ -127,21 +128,21 @@ export async function update(ctx: any) { try { const isCustom = poolKey.startsWith('custom:') if (isCustom) { - const config = await readConfigYaml() - if (!Array.isArray(config.custom_providers)) { - ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return - } - const entry = (config.custom_providers as any[]).find((e: any) => { - return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey + const found = await updateConfigYaml((config) => { + if (!Array.isArray(config.custom_providers)) return { data: config, result: false, write: false } + const entry = (config.custom_providers as any[]).find((e: any) => { + return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey + }) + if (!entry) return { data: config, result: false, write: false } + if (name !== undefined) entry.name = name + if (base_url !== undefined) entry.base_url = base_url + if (api_key !== undefined) entry.api_key = api_key + if (model !== undefined) entry.model = model + return { data: config, result: true } }) - if (!entry) { + if (!found) { ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return } - if (name !== undefined) entry.name = name - if (base_url !== undefined) entry.base_url = base_url - if (api_key !== undefined) entry.api_key = api_key - if (model !== undefined) entry.model = model - await writeConfigYaml(config) } else { const envMapping = PROVIDER_ENV_MAP[poolKey] if (!envMapping?.api_key_env) { @@ -160,46 +161,49 @@ export async function update(ctx: any) { export async function remove(ctx: any) { const poolKey = decodeURIComponent(ctx.params.poolKey) try { - const config = await readConfigYaml() const isCustom = poolKey.startsWith('custom:') - if (isCustom) { - const idx = Array.isArray(config.custom_providers) - ? (config.custom_providers as any[]).findIndex((e: any) => { - return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey - }) - : -1 - if (idx === -1) { - ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return - } - ;(config.custom_providers as any[]).splice(idx, 1) - await writeConfigYaml(config) - await clearStoredAuthProvider(poolKey) - } else { - const envMapping = PROVIDER_ENV_MAP[poolKey] - if (envMapping?.api_key_env) { - await saveEnvValue(envMapping.api_key_env, '') - if (envMapping.base_url_env) { await saveEnvValue(envMapping.base_url_env, '') } - } - await clearStoredAuthProvider(poolKey) - } - const currentProvider = config.model?.provider - if (currentProvider === poolKey) { - const freshConfig = await readConfigYaml() - const remaining = Array.isArray(freshConfig.custom_providers) ? freshConfig.custom_providers as any[] : [] - if (remaining.length > 0) { - const fallbackCp = remaining[0] - const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}` - if (typeof freshConfig.model !== 'object' || freshConfig.model === null) { freshConfig.model = {} } - freshConfig.model.default = fallbackCp.model - freshConfig.model.provider = fallbackKey - delete freshConfig.model.base_url - delete freshConfig.model.api_key - await writeConfigYaml(freshConfig) + const removed = await updateConfigYaml(async (config) => { + if (isCustom) { + const idx = Array.isArray(config.custom_providers) + ? (config.custom_providers as any[]).findIndex((e: any) => { + return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey + }) + : -1 + if (idx === -1) return { data: config, result: false, write: false } + ;(config.custom_providers as any[]).splice(idx, 1) } else { - freshConfig.model = {} - await writeConfigYaml(freshConfig) + const envMapping = PROVIDER_ENV_MAP[poolKey] + if (envMapping?.api_key_env) { + await saveEnvValue(envMapping.api_key_env, '') + if (envMapping.base_url_env) { await saveEnvValue(envMapping.base_url_env, '') } + } + } + if (config.model?.provider === poolKey) { + const remaining = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : [] + if (remaining.length > 0) { + const fallbackCp = remaining[0] + const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}` + if (typeof config.model !== 'object' || config.model === null) { config.model = {} } + config.model.default = fallbackCp.model + config.model.provider = fallbackKey + delete config.model.base_url + delete config.model.api_key + } else { + config.model = {} + } + } + return { data: config, result: true } + }) + if (!removed) { + ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return + } + if (!isCustom) { + const envMapping = PROVIDER_ENV_MAP[poolKey] + if (!envMapping) { + ctx.status = 404; ctx.body = { error: `Provider "${poolKey}" not found` }; return } } + await clearStoredAuthProvider(poolKey) // TODO: Test if provider works without gateway restart // try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') } ctx.body = { success: true } diff --git a/packages/server/src/controllers/hermes/skills.ts b/packages/server/src/controllers/hermes/skills.ts index f0e4f06..e1b1968 100644 --- a/packages/server/src/controllers/hermes/skills.ts +++ b/packages/server/src/controllers/hermes/skills.ts @@ -2,7 +2,7 @@ import { readdir, readFile } from 'fs/promises' import { join, resolve } from 'path' import { createHash } from 'crypto' import { - readConfigYaml, writeConfigYaml, + readConfigYaml, updateConfigYaml, safeReadFile, extractDescription, listFilesRecursive, getHermesDir, } from '../../services/config-helpers' import { pinSkill } from '../../services/hermes/hermes-cli' @@ -260,14 +260,15 @@ export async function toggle(ctx: any) { return } try { - const config = await readConfigYaml() - if (!config.skills) config.skills = {} - if (!Array.isArray(config.skills.disabled)) config.skills.disabled = [] - const disabled = config.skills.disabled as string[] - const idx = disabled.indexOf(name) - if (enabled) { if (idx !== -1) disabled.splice(idx, 1) } - else { if (idx === -1) disabled.push(name) } - await writeConfigYaml(config) + await updateConfigYaml((config) => { + if (!config.skills) config.skills = {} + if (!Array.isArray(config.skills.disabled)) config.skills.disabled = [] + const disabled = config.skills.disabled as string[] + const idx = disabled.indexOf(name) + if (enabled) { if (idx !== -1) disabled.splice(idx, 1) } + else { if (idx === -1) disabled.push(name) } + return config + }) ctx.body = { success: true } } catch (err: any) { ctx.status = 500 diff --git a/packages/server/src/controllers/hermes/weixin.ts b/packages/server/src/controllers/hermes/weixin.ts index 98f5d42..50ab367 100644 --- a/packages/server/src/controllers/hermes/weixin.ts +++ b/packages/server/src/controllers/hermes/weixin.ts @@ -1,7 +1,8 @@ import axios from 'axios' -import { readFile, writeFile, chmod } from 'fs/promises' +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' const ILINK_BASE = 'https://ilinkai.weixin.qq.com' const envPath = () => getActiveEnvPath() @@ -38,27 +39,26 @@ 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 { - let raw: string - try { raw = await readFile(envPath(), 'utf-8') } catch { raw = '' } const entries: Record = { WEIXIN_ACCOUNT_ID: account_id, WEIXIN_TOKEN: token } if (base_url) entries.WEIXIN_BASE_URL = base_url - 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}`) } } - let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' const ep = envPath() - await writeFile(ep, output, 'utf-8') + 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() ctx.body = { success: true } diff --git a/packages/server/src/services/config-helpers.ts b/packages/server/src/services/config-helpers.ts index 357efff..2b45fe2 100644 --- a/packages/server/src/services/config-helpers.ts +++ b/packages/server/src/services/config-helpers.ts @@ -1,10 +1,10 @@ -import { readFile, writeFile, copyFile, chmod } from 'fs/promises' +import { readFile, chmod } from 'fs/promises' import { readdir, stat } from 'fs/promises' import { existsSync, readFileSync } from 'fs' import { join } from 'path' -import YAML from 'js-yaml' import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from './hermes/hermes-profile' import { logger } from './logger' +import { safeFileStore } from './safe-file-store' // --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) --- export const PROVIDER_ENV_MAP: Record = { @@ -72,56 +72,48 @@ export interface ModelGroup { const configPath = () => getActiveConfigPath() export async function readConfigYaml(): Promise> { - const raw = await safeReadFile(configPath()) - if (!raw) return {} - return (YAML.load(raw, { json: true }) as Record) || {} + return safeFileStore.readYaml(configPath()) } export async function writeConfigYaml(config: Record): Promise { - const cp = configPath() - await copyFile(cp, cp + '.bak') - const yamlStr = YAML.dump(config, { - lineWidth: -1, - noRefs: true, - quotingType: '"', - }) - await writeFile(cp, yamlStr, 'utf-8') + await safeFileStore.writeYaml(configPath(), config, { backup: true }) +} + +export async function updateConfigYaml( + updater: (config: Record) => Record | { data: Record; result: T; write?: boolean } | Promise | { data: Record; result: T; write?: boolean }>, +): Promise { + return safeFileStore.updateYaml(configPath(), updater, { backup: true }) } // --- .env helpers --- export async function saveEnvValue(key: string, value: string): Promise { const envPath = getActiveEnvPath() - let raw: string - try { - raw = await readFile(envPath, 'utf-8') - } catch { - raw = '' - } - const remove = !value - const lines = raw.split('\n') - let found = false - const result: string[] = [] - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith('#') && trimmed.startsWith(`# ${key}=`)) { - if (!remove) result.push(`${key}=${value}`) - found = true - } else { - const eqIdx = trimmed.indexOf('=') - if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) { + await safeFileStore.updateText(envPath, (raw) => { + const remove = !value + const lines = raw.split('\n') + let found = false + const result: string[] = [] + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('#') && trimmed.startsWith(`# ${key}=`)) { if (!remove) result.push(`${key}=${value}`) found = true } else { - result.push(line) + const eqIdx = trimmed.indexOf('=') + if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) { + if (!remove) result.push(`${key}=${value}`) + found = true + } else { + result.push(line) + } } } - } - if (!found && !remove) { - result.push(`${key}=${value}`) - } - let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' - await writeFile(envPath, output, 'utf-8') + if (!found && !remove) { + result.push(`${key}=${value}`) + } + return result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' + }) try { await chmod(envPath, 0o600) } catch { /* ignore */ } } diff --git a/packages/server/src/services/hermes/gateway-manager.ts b/packages/server/src/services/hermes/gateway-manager.ts index 5154db6..2f90328 100644 --- a/packages/server/src/services/hermes/gateway-manager.ts +++ b/packages/server/src/services/hermes/gateway-manager.ts @@ -7,39 +7,36 @@ * 3. 启动/停止网关进程 * * 启动检测流程(detectStatus): - * ① 读取 gateway.pid → 获取 PID + * ① 读取 gateway.pid,缺失时回退读取 gateway_state.json → 获取 PID * ② 读取 config.yaml (platforms.api_server.extra.port/host) → 获取配置端口 - * ③ PID 存活? - * - 否 → 标记为 stopped - * - 是 → 继续 - * ④ 对配置端口做 health check? - * - 通过 → 配置与运行状态匹配,注册网关 - * - 失败 → 用 lsof 查 PID 实际监听端口 - * ⑤ 实际端口 ≠ 配置端口? - * - 是 → 更新 config.yaml 到实际端口,重新 health check,通过则注册 + * ③ PID 存活且配置端口 health check 通过? + * - 是 → 配置与运行状态匹配,注册网关 * - 否 → 标记为 stopped * + * detectStatus 只做只读检测:不会认领未知端口上的进程,也不会探测实际监听端口后回写 + * config.yaml。端口修正发生在启动前的 resolvePort 阶段。 + * * 端口分配流程(resolvePort,启动前调用): * ① 读取配置端口 - * ② 检查是否被已管理的网关占用 - * ③ 检查是否被外部系统进程占用(TCP bind 测试) - * ④ 冲突则从 base+1 递增找空闲端口,并写入 config.yaml + * ② 如果内存记录或 PID 文件对应的配置端口仍健康运行,复用该端口 + * ③ 收集本轮已分配端口、其他已管理网关端口、Web UI 端口 + * ④ 从 8642 起递增查找空闲端口,并写入 config.yaml * * 启动模式: - * - 正常系统(macOS/Linux):hermes gateway start/stop(系统服务管理) - * - WSL / Docker:hermes gateway run(detached 子进程,手动 kill) + * - 所有平台统一使用 `hermes gateway run --replace` + * - 停止时先尝试 `hermes gateway stop`,再根据 PID / 监听端口清理进程 */ -import { spawn, execSync, type ChildProcess } from 'child_process' -import { resolve, join } from 'path' -import { homedir } from 'os' -import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs' +import { spawn, type ChildProcess } from 'child_process' +import { join } from 'path' +import { readFileSync, existsSync, readdirSync, unlinkSync } from 'fs' import { execFile } from 'child_process' import { promisify } from 'util' import { createServer } from 'net' import yaml from 'js-yaml' import { logger } from '../logger' import { detectHermesHome, getHermesBin } from './hermes-path' +import { safeFileStore } from '../safe-file-store' const execFileAsync = promisify(execFile) @@ -51,63 +48,6 @@ const HERMES_BASE = detectHermesHome() const HERMES_BIN = getHermesBin() const DEFAULT_WEB_UI_PORT = 8648 -/** - * 检测系统的 init 系统(服务管理器) - * - macOS → launchd - * - Windows → windows-service - * - Linux → systemd / sysvinit / other - * - * 没有 systemd/launchd/windows-service 的环境需要用 "gateway run" 代替 "gateway start" - * (适用于 WSL/Docker/Termux/proot 等无服务管理器的环境) - */ -function detectInitSystem(): string { - const platform = process.platform - - // macOS → launchd - if (platform === 'darwin') { - return 'launchd' - } - - // Windows → Service Manager - if (platform === 'win32') { - return 'windows-service' - } - - // Linux 才检查 /proc - if (platform === 'linux') { - try { - if (existsSync('/.dockerenv') || existsSync('/run/.containerenv')) { - return 'container' - } - - const comm = readFileSync('/proc/1/comm', 'utf-8').trim() - - if (comm === 'systemd') { - return existsSync('/run/systemd/system') ? 'systemd' : 'other' - } - if (comm === 'init') return 'sysvinit' - - return 'other' - } catch { - return 'unknown' - } - } - - return 'unknown' -} - -// 注意:虽然此函数仍然存在,但当前所有平台都统一使用 run 模式 -// 保留此函数是为了将来如果需要切换回 start/stop 模式时可以参考 -const initSystem = detectInitSystem() -/** - * 所有平台统一使用 run 模式 - * run 模式会自动处理锁定文件冲突(--replace 标志),更可靠 - * 子进程跟随父进程生命周期,父进程关闭时子进程自动关闭 - */ -const needsRunMode = true -// 启动时输出 init 系统检测结果(方便调试) -logger.debug('Detected init system: %s (needsRunMode: %s, platform: %s)', initSystem, needsRunMode, process.platform) - const GATEWAY_RUNTIME_ENV_KEYS = new Set([ 'PATH', 'HOME', @@ -399,32 +339,30 @@ export class GatewayManager { * host: * 同时清理旧的顶层 port/host(避免 Hermes 读取错误) */ - private writeProfilePort(name: string, port: number, host: string): void { + private async writeProfilePort(name: string, port: number, host: string): Promise { const configPath = join(this.profileDir(name), 'config.yaml') try { - const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' - const cfg = (yaml.load(content, { json: true }) as any) || {} + await safeFileStore.updateYaml(configPath, (cfg) => { + // 确保 platforms.api_server 结构存在(不会影响其他位置的 platforms) + if (!cfg.platforms) cfg.platforms = {} + if (!cfg.platforms.api_server) cfg.platforms.api_server = {} + if (!cfg.platforms.api_server.extra) cfg.platforms.api_server.extra = {} - // 确保 platforms.api_server 结构存在(不会影响其他位置的 platforms) - if (!cfg.platforms) cfg.platforms = {} - if (!cfg.platforms.api_server) cfg.platforms.api_server = {} - if (!cfg.platforms.api_server.extra) cfg.platforms.api_server.extra = {} + cfg.platforms.api_server.enabled = true + cfg.platforms.api_server.key = '' + cfg.platforms.api_server.cors_origins = '*' + cfg.platforms.api_server.extra.port = port + cfg.platforms.api_server.extra.host = host - cfg.platforms.api_server.enabled = true - cfg.platforms.api_server.key = '' - cfg.platforms.api_server.cors_origins = '*' - cfg.platforms.api_server.extra.port = port - cfg.platforms.api_server.extra.host = host - - // 清理旧的顶层 port/host,Hermes 只从 extra 读取 - if (cfg.platforms.api_server.port !== undefined) { - delete cfg.platforms.api_server.port - } - if (cfg.platforms.api_server.host !== undefined) { - delete cfg.platforms.api_server.host - } - - writeFileSync(configPath, yaml.dump(cfg, { lineWidth: -1 }), 'utf-8') + // 清理旧的顶层 port/host,Hermes 只从 extra 读取 + if (cfg.platforms.api_server.port !== undefined) { + delete cfg.platforms.api_server.port + } + if (cfg.platforms.api_server.host !== undefined) { + delete cfg.platforms.api_server.host + } + return cfg + }) logger.debug('Updated %s: api_server.extra.port = %d', configPath, port) } catch (err) { logger.error(err, 'Failed to write config for profile "%s"', name) @@ -487,7 +425,7 @@ export class GatewayManager { } else { logger.debug('Assigning port %d for profile "%s"', port, name) } - this.writeProfilePort(name, port, host) + await this.writeProfilePort(name, port, host) this.allocatedPorts.add(port) return { port, host } diff --git a/packages/server/src/services/safe-file-store.ts b/packages/server/src/services/safe-file-store.ts new file mode 100644 index 0000000..825e808 --- /dev/null +++ b/packages/server/src/services/safe-file-store.ts @@ -0,0 +1,144 @@ +import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'fs/promises' +import { dirname, resolve } from 'path' +import { randomUUID } from 'crypto' +import YAML, { type DumpOptions } from 'js-yaml' + +type TextUpdater = (current: string) => string | { content: string; result: T } | Promise +type YamlUpdateResult = { data: Record; result: T; write?: boolean } +type YamlUpdater = (current: Record) => Record | YamlUpdateResult | Promise | YamlUpdateResult> + +export interface SafeWriteOptions { + backup?: boolean + backupPath?: string +} + +export interface SafeYamlOptions extends SafeWriteOptions { + dumpOptions?: DumpOptions +} + +function isTextUpdateResult(value: unknown): value is { content: string; result: T } { + return !!value && typeof value === 'object' && Object.hasOwn(value as Record, 'content') +} + +function isYamlUpdateResult(value: unknown): value is YamlUpdateResult { + return !!value && typeof value === 'object' && Object.hasOwn(value as Record, 'data') +} + +export class SafeFileStore { + private queues = new Map>() + + private normalizePath(filePath: string): string { + return resolve(filePath) + } + + private async withLock(filePath: string, task: () => Promise): Promise { + const key = this.normalizePath(filePath) + const previous = this.queues.get(key) || Promise.resolve() + let release!: () => void + const current = new Promise(resolve => { release = resolve }) + const next = previous.then(() => current, () => current) + this.queues.set(key, next) + + await previous.catch(() => undefined) + try { + return await task() + } finally { + release() + if (this.queues.get(key) === next) this.queues.delete(key) + } + } + + async readText(filePath: string): Promise { + return readFile(this.normalizePath(filePath), 'utf-8') + } + + async writeText(filePath: string, content: string, options: SafeWriteOptions = {}): Promise { + await this.withLock(filePath, () => this.writeTextUnlocked(filePath, content, options)) + } + + async updateText(filePath: string, updater: TextUpdater, options: SafeWriteOptions = {}): Promise { + return this.withLock(filePath, async () => { + let current = '' + try { + current = await this.readText(filePath) + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err + } + + const updated = await updater(current) + const content = isTextUpdateResult(updated) ? updated.content : updated + await this.writeTextUnlocked(filePath, content, options) + return isTextUpdateResult(updated) ? updated.result : undefined + }) + } + + async readYaml(filePath: string): Promise> { + try { + const raw = await this.readText(filePath) + return (YAML.load(raw, { json: true }) as Record) || {} + } catch (err: any) { + if (err?.code === 'ENOENT') return {} + throw err + } + } + + async writeYaml(filePath: string, data: Record, options: SafeYamlOptions = {}): Promise { + const yamlStr = YAML.dump(data, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + ...(options.dumpOptions || {}), + }) + await this.writeText(filePath, yamlStr, options) + } + + async updateYaml(filePath: string, updater: YamlUpdater, options: SafeYamlOptions = {}): Promise { + return this.withLock(filePath, async () => { + let raw = '' + try { + raw = await this.readText(filePath) + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err + } + const current = raw ? ((YAML.load(raw, { json: true }) as Record) || {}) : {} + const updated = await updater(current) + const data = isYamlUpdateResult(updated) ? updated.data : updated + if (isYamlUpdateResult(updated) && updated.write === false) { + return updated.result + } + const yamlStr = YAML.dump(data, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + ...(options.dumpOptions || {}), + }) + await this.writeTextUnlocked(filePath, yamlStr, options) + return isYamlUpdateResult(updated) ? updated.result : undefined + }) + } + + private async writeTextUnlocked(filePath: string, content: string, options: SafeWriteOptions): Promise { + const target = this.normalizePath(filePath) + const dir = dirname(target) + const temp = `${target}.tmp.${process.pid}.${Date.now()}.${randomUUID()}` + + await mkdir(dir, { recursive: true }) + if (options.backup) { + try { + await copyFile(target, options.backupPath || `${target}.bak`) + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err + } + } + + try { + await writeFile(temp, content, 'utf-8') + await rename(temp, target) + } catch (err) { + await rm(temp, { force: true }).catch(() => undefined) + throw err + } + } +} + +export const safeFileStore = new SafeFileStore() diff --git a/packages/server/src/shared/providers.ts b/packages/server/src/shared/providers.ts index df88660..b92d9b3 100644 --- a/packages/server/src/shared/providers.ts +++ b/packages/server/src/shared/providers.ts @@ -86,7 +86,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ value: 'glm-coding-plan', builtin: true, base_url: 'https://api.z.ai/api/anthropic', - models: ['glm-5.1', 'glm-5', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'], + models: ['glm-5.1', 'glm-5', 'glm-5v-turbo', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'], }, { label: 'Kimi for Coding', diff --git a/tests/server/config-controller-file-lock.test.ts b/tests/server/config-controller-file-lock.test.ts new file mode 100644 index 0000000..11cd80c --- /dev/null +++ b/tests/server/config-controller-file-lock.test.ts @@ -0,0 +1,108 @@ +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 { mockGatewayManager } = vi.hoisted(() => ({ + mockGatewayManager: { + getActiveProfile: vi.fn(() => 'default'), + stop: vi.fn().mockResolvedValue(undefined), + start: vi.fn().mockResolvedValue(undefined), + }, +})) + +vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({ + getGatewayManagerInstance: () => mockGatewayManager, +})) + +const originalHermesHome = process.env.HERMES_HOME +const tempHomes: string[] = [] +let hermesHome = '' + +async function loadController() { + vi.resetModules() + process.env.HERMES_HOME = hermesHome + return import('../../packages/server/src/controllers/hermes/config') +} + +function makeCtx(body: unknown): any { + return { request: { body }, query: {}, status: 200, body: undefined } +} + +beforeEach(async () => { + vi.clearAllMocks() + 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 + 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 platform gateways', 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(mockGatewayManager.stop).toHaveBeenCalledWith('default') + expect(mockGatewayManager.start).toHaveBeenCalledWith('default') + 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('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') + }) +}) diff --git a/tests/server/config-helpers-file-lock.test.ts b/tests/server/config-helpers-file-lock.test.ts new file mode 100644 index 0000000..cda2f1c --- /dev/null +++ b/tests/server/config-helpers-file-lock.test.ts @@ -0,0 +1,82 @@ +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 originalHermesHome = process.env.HERMES_HOME +const tempHomes: string[] = [] +let hermesHome = '' + +async function loadHelpers() { + vi.resetModules() + process.env.HERMES_HOME = hermesHome + return import('../../packages/server/src/services/config-helpers') +} + +beforeEach(async () => { + hermesHome = await mkdtemp(join(tmpdir(), 'hermes-config-helpers-')) + 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('config-helpers locked file updates', () => { + it('merges concurrent config.yaml updates by re-reading under the file lock', async () => { + await writeFile(join(hermesHome, 'config.yaml'), 'model:\n default: old\n', 'utf-8') + const { updateConfigYaml } = await loadHelpers() + + await Promise.all([ + updateConfigYaml(async (cfg) => { + await new Promise(resolve => setTimeout(resolve, 25)) + cfg.model.default = 'glm-5.1' + return cfg + }), + updateConfigYaml((cfg) => { + cfg.platforms = cfg.platforms || {} + cfg.platforms.api_server = { extra: { port: 8648 } } + return cfg + }), + ]) + + const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any + expect(config.model.default).toBe('glm-5.1') + expect(config.platforms.api_server.extra.port).toBe(8648) + await expect(readFile(join(hermesHome, 'config.yaml.bak'), 'utf-8')).resolves.toContain('model:') + }) + + it('serializes concurrent .env updates without losing keys', async () => { + await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8') + const { saveEnvValue } = await loadHelpers() + + await Promise.all([ + saveEnvValue('DEEPSEEK_API_KEY', 'deepseek'), + saveEnvValue('MOONSHOT_API_KEY', 'moonshot'), + ]) + + const env = await readFile(join(hermesHome, '.env'), 'utf-8') + expect(env).toContain('OPENROUTER_API_KEY=keep') + expect(env).toContain('DEEPSEEK_API_KEY=deepseek') + expect(env).toContain('MOONSHOT_API_KEY=moonshot') + }) + + it('skips writing config.yaml when an updater returns write false', async () => { + const configPath = join(hermesHome, 'config.yaml') + await writeFile(configPath, 'model:\n default: old\n', 'utf-8') + const before = await readFile(configPath, 'utf-8') + const { updateConfigYaml } = await loadHelpers() + + const result = await updateConfigYaml((cfg) => ({ data: cfg, result: 'unchanged', write: false })) + + expect(result).toBe('unchanged') + await expect(readFile(configPath, 'utf-8')).resolves.toBe(before) + await expect(readFile(`${configPath}.bak`, 'utf-8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/tests/server/config-mutating-controllers.test.ts b/tests/server/config-mutating-controllers.test.ts new file mode 100644 index 0000000..ff0a4c6 --- /dev/null +++ b/tests/server/config-mutating-controllers.test.ts @@ -0,0 +1,96 @@ +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' + +vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({ + pinSkill: vi.fn(), +})) + +vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ + getSkillUsageStatsFromDb: vi.fn(), +})) + +vi.mock('../../packages/server/src/db', () => ({ + getDb: vi.fn(), +})) + +vi.mock('../../packages/server/src/db/hermes/schemas', () => ({ + MODEL_CONTEXT_TABLE: 'model_context', +})) + +const originalHermesHome = process.env.HERMES_HOME +const tempHomes: string[] = [] +let hermesHome = '' + +async function loadModelsController() { + vi.resetModules() + process.env.HERMES_HOME = hermesHome + return import('../../packages/server/src/controllers/hermes/models') +} + +async function loadSkillsController() { + vi.resetModules() + process.env.HERMES_HOME = hermesHome + return import('../../packages/server/src/controllers/hermes/skills') +} + +function makeCtx(body: unknown): any { + return { request: { body }, status: 200, body: undefined, query: {}, params: {} } +} + +beforeEach(async () => { + 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 + await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) + hermesHome = '' +}) + +describe('config mutating controllers', () => { + it('setConfigModel updates only the model section and preserves existing config', async () => { + await writeFile(join(hermesHome, 'config.yaml'), [ + 'terminal:', + ' backend: local', + 'model:', + ' default: old', + ' provider: old-provider', + '', + ].join('\n'), 'utf-8') + const { setConfigModel } = await loadModelsController() + const ctx = makeCtx({ default: 'glm-5.1', provider: 'custom:glm' }) + + await setConfigModel(ctx) + + expect(ctx.body).toEqual({ success: true }) + const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any + expect(config.model).toEqual({ default: 'glm-5.1', provider: 'custom:glm' }) + expect(config.terminal.backend).toBe('local') + }) + + it('skill toggle preserves unrelated config while adding and removing disabled skills', async () => { + await writeFile(join(hermesHome, 'config.yaml'), [ + 'model:', + ' default: glm-5.1', + 'skills:', + ' disabled:', + ' - old-skill', + '', + ].join('\n'), 'utf-8') + const { toggle } = await loadSkillsController() + + await toggle(makeCtx({ name: 'new-skill', enabled: false })) + await toggle(makeCtx({ name: 'old-skill', enabled: true })) + + const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any + expect(config.model.default).toBe('glm-5.1') + expect(config.skills.disabled).toEqual(['new-skill']) + }) +}) diff --git a/tests/server/copilot-auth-controller.test.ts b/tests/server/copilot-auth-controller.test.ts index 47890d4..0dd2ac7 100644 --- a/tests/server/copilot-auth-controller.test.ts +++ b/tests/server/copilot-auth-controller.test.ts @@ -5,13 +5,14 @@ vi.mock('os', async () => { return { ...actual, homedir: () => '/fake/home' } }) -const { mockReadFile, mockWriteFile, mockMkdir, mockSaveEnvValue, mockReadConfigYaml, mockWriteConfigYaml, mockResolveWithSource, mockInvalidate, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({ +const { mockReadFile, mockWriteFile, mockMkdir, mockSaveEnvValue, mockReadConfigYaml, mockWriteConfigYaml, mockUpdateConfigYaml, mockResolveWithSource, mockInvalidate, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({ mockReadFile: vi.fn(), mockWriteFile: vi.fn().mockResolvedValue(undefined), mockMkdir: vi.fn().mockResolvedValue(undefined), mockSaveEnvValue: vi.fn().mockResolvedValue(undefined), mockReadConfigYaml: vi.fn(), mockWriteConfigYaml: vi.fn().mockResolvedValue(undefined), + mockUpdateConfigYaml: vi.fn(), mockResolveWithSource: vi.fn(), mockInvalidate: vi.fn(), mockReadAppConfig: vi.fn(), @@ -28,6 +29,7 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({ saveEnvValue: mockSaveEnvValue, readConfigYaml: mockReadConfigYaml, writeConfigYaml: mockWriteConfigYaml, + updateConfigYaml: mockUpdateConfigYaml, })) vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({ @@ -58,6 +60,17 @@ beforeEach(() => { vi.clearAllMocks() mockReadFile.mockResolvedValue('') mockReadConfigYaml.mockResolvedValue({}) + mockUpdateConfigYaml.mockImplementation(async (updater: any) => { + const cfg = await mockReadConfigYaml() + const updated = await updater(cfg) + if (updated && typeof updated === 'object' && Object.hasOwn(updated, 'data')) { + if (updated.write === false) return updated.result + await mockWriteConfigYaml(updated.data) + return updated.result + } + await mockWriteConfigYaml(updated) + return undefined + }) }) afterEach(() => { diff --git a/tests/server/safe-file-store.test.ts b/tests/server/safe-file-store.test.ts new file mode 100644 index 0000000..718c80c --- /dev/null +++ b/tests/server/safe-file-store.test.ts @@ -0,0 +1,54 @@ +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import YAML from 'js-yaml' +import { SafeFileStore } from '../../packages/server/src/services/safe-file-store' + +const tempDirs: string[] = [] + +async function tempFile(name: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'hermes-safe-file-store-')) + tempDirs.push(dir) + return join(dir, name) +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('SafeFileStore', () => { + it('serializes concurrent YAML read-modify-write updates for the same file', async () => { + const store = new SafeFileStore() + const file = await tempFile('config.yaml') + await writeFile(file, 'model:\n default: old\n', 'utf-8') + + await Promise.all([ + store.updateYaml(file, async (cfg) => { + await new Promise(resolve => setTimeout(resolve, 25)) + cfg.model.default = 'glm-5.1' + return cfg + }), + store.updateYaml(file, (cfg) => { + cfg.platforms = cfg.platforms || {} + cfg.platforms.api_server = { extra: { port: 8648 } } + return cfg + }), + ]) + + const result = YAML.load(await readFile(file, 'utf-8')) as any + expect(result.model.default).toBe('glm-5.1') + expect(result.platforms.api_server.extra.port).toBe(8648) + }) + + it('backs up the previous content and writes through a temporary file', async () => { + const store = new SafeFileStore() + const file = await tempFile('config.yaml') + await writeFile(file, 'model:\n default: old\n', 'utf-8') + + await store.writeText(file, 'model:\n default: new\n', { backup: true }) + + await expect(readFile(`${file}.bak`, 'utf-8')).resolves.toContain('default: old') + await expect(readFile(file, 'utf-8')).resolves.toContain('default: new') + }) +}) diff --git a/tests/server/weixin-controller.test.ts b/tests/server/weixin-controller.test.ts new file mode 100644 index 0000000..a31876f --- /dev/null +++ b/tests/server/weixin-controller.test.ts @@ -0,0 +1,77 @@ +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' + +const { mockRestartGateway } = vi.hoisted(() => ({ + mockRestartGateway: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({ + restartGateway: mockRestartGateway, +})) + +const originalHermesHome = process.env.HERMES_HOME +const tempHomes: string[] = [] +let hermesHome = '' + +async function loadController() { + vi.resetModules() + process.env.HERMES_HOME = hermesHome + return import('../../packages/server/src/controllers/hermes/weixin') +} + +function makeCtx(body: unknown): any { + return { request: { body }, 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 () => { + await writeFile(join(hermesHome, '.env'), [ + 'OPENROUTER_API_KEY=keep', + 'WEIXIN_TOKEN=old-token', + '', + ].join('\n'), 'utf-8') + const { save } = await loadController() + const ctx = makeCtx({ account_id: 'acct-1', token: 'new-token', base_url: 'https://weixin.local' }) + + 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') + }) + + it('rejects missing required credentials without touching .env', async () => { + await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8') + const { save } = await loadController() + const ctx = makeCtx({ account_id: 'acct-1' }) + + 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') + }) +})