[codex] add locked file updates for config writes (#785)

* add locked file updates for config writes

* add glm vision turbo preset
This commit is contained in:
ekko
2026-05-16 13:11:59 +08:00
committed by GitHub
parent 217b721648
commit 67723d9315
16 changed files with 822 additions and 314 deletions
@@ -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<Record<string, any>> {
}
async function readConfig(): Promise<Record<string, any>> {
const raw = await readFile(configPath(), 'utf-8')
return (YAML.load(raw, { json: true }) as Record<string, any>) || {}
}
async function writeConfig(data: Record<string, any>): Promise<void> {
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<string, any> = {}
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<string, any>)) { 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()
@@ -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<void> {
// 不能 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
@@ -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
@@ -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 }
@@ -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
@@ -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<string, string> = { 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<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}`) } }
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<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()
ctx.body = { success: true }