| 1 | import {readFile, rename, writeFile} from 'node:fs/promises'; |
| 2 | import path from 'node:path'; |
| 3 | import {parse, stringify} from 'yaml'; |
| 4 | |
| 5 | const SECTION_FIELDS = { |
| 6 | llm: ['model_provider', 'model', 'base_url'], |
| 7 | image: ['model', 'base_url'], |
| 8 | video: ['model', 'base_url'], |
| 9 | embedding: ['model_provider', 'model', 'base_url'], |
| 10 | reranker: ['model', 'base_url'], |
| 11 | }; |
| 12 | |
| 13 | export async function readAgentConfig(repoRoot) { |
| 14 | const {payload} = await loadConfig(repoRoot); |
| 15 | return publicConfig(payload); |
| 16 | } |
| 17 | |
| 18 | export async function saveAgentConfig(repoRoot, input) { |
| 19 | if (!input || typeof input !== 'object' || !input.sections || typeof input.sections !== 'object') { |
| 20 | throw new Error('Configuration sections are required'); |
| 21 | } |
| 22 | const {configPath, payload} = await loadConfig(repoRoot); |
| 23 | for (const [section, fields] of Object.entries(SECTION_FIELDS)) { |
| 24 | const update = input.sections[section]; |
| 25 | if (!update || typeof update !== 'object') continue; |
| 26 | const current = payload[section] && typeof payload[section] === 'object' ? payload[section] : {}; |
| 27 | for (const field of fields) { |
| 28 | if (!(field in update)) continue; |
| 29 | current[field] = validatedValue(update[field], `${section}.${field}`, 2_048); |
| 30 | } |
| 31 | if (typeof update.api_key === 'string' && update.api_key.trim()) { |
| 32 | current.api_key = validatedValue(update.api_key, `${section}.api_key`, 8_192); |
| 33 | } |
| 34 | payload[section] = current; |
| 35 | } |
| 36 | const temporaryPath = `${configPath}.${process.pid}.tmp`; |
| 37 | await writeFile(temporaryPath, stringify(payload, {lineWidth: 0}), {mode: 0o600}); |
| 38 | await rename(temporaryPath, configPath); |
| 39 | return publicConfig(payload); |
| 40 | } |
| 41 | |
| 42 | async function loadConfig(repoRoot) { |
| 43 | const configPath = path.join(repoRoot, 'configs', 'agent.local.yaml'); |
| 44 | let text = ''; |
| 45 | try { |
| 46 | text = await readFile(configPath, 'utf8'); |
| 47 | } catch (error) { |
| 48 | if (error?.code !== 'ENOENT') throw error; |
| 49 | } |
| 50 | const payload = text ? parse(text) : {}; |
| 51 | if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { |
| 52 | throw new Error('configs/agent.local.yaml must be a YAML mapping'); |
| 53 | } |
| 54 | return {configPath, payload}; |
| 55 | } |
| 56 | |
| 57 | function publicConfig(payload) { |
| 58 | const sections = {}; |
| 59 | for (const [section, fields] of Object.entries(SECTION_FIELDS)) { |
| 60 | const source = payload[section] && typeof payload[section] === 'object' ? payload[section] : {}; |
| 61 | const result = {}; |
| 62 | for (const field of fields) result[field] = typeof source[field] === 'string' ? source[field] : ''; |
| 63 | result.api_key = ''; |
| 64 | result.has_api_key = Boolean(typeof source.api_key === 'string' && source.api_key.trim()); |
| 65 | sections[section] = result; |
| 66 | } |
| 67 | return {sections}; |
| 68 | } |
| 69 | |
| 70 | function validatedValue(value, label, maxLength) { |
| 71 | if (typeof value !== 'string') throw new Error(`${label} must be a string`); |
| 72 | const normalized = value.trim(); |
| 73 | if (normalized.length > maxLength) throw new Error(`${label} is too long`); |
| 74 | if (label.endsWith('.base_url') && normalized && !/^https?:\/\//i.test(normalized)) { |
| 75 | throw new Error(`${label} must use http:// or https://`); |
| 76 | } |
| 77 | return normalized; |
| 78 | } |
| 79 |