/** * Agent Config Sync * * Reads agents from openclaw.json and upserts them into the MC database. * Used by both the /api/agents/sync endpoint and the startup scheduler. */ import { config } from './config' import { getDatabase, db_helpers, logAuditEvent } from './db' import { eventBus } from './event-bus' import { join, isAbsolute, resolve } from 'path' import { existsSync, readFileSync, statSync } from 'fs' import { resolveWithin } from './paths' import { logger } from './logger' import { parseJsonRelaxed } from './json-relaxed' interface OpenClawAgent { id: string name?: string default?: boolean workspace?: string agentDir?: string model?: { primary?: string fallbacks?: string[] } identity?: { name?: string theme?: string emoji?: string } subagents?: any sandbox?: { mode?: string workspaceAccess?: string scope?: string docker?: any } tools?: { allow?: string[] deny?: string[] } memorySearch?: any } export interface SyncResult { synced: number created: number updated: number agents: Array<{ id: string name: string action: 'created' | 'updated' | 'unchanged' }> error?: string } export interface SyncDiff { inConfig: number inMC: number newAgents: string[] updatedAgents: string[] onlyInMC: string[] } function parseIdentityFromFile(content: string): { name?: string; theme?: string; emoji?: string; content?: string } { if (!content.trim()) return {} const lines = content.split('\n').map((line) => line.trim()).filter(Boolean) let name: string | undefined let theme: string | undefined let emoji: string | undefined for (const line of lines) { if (!name && line.startsWith('#')) { name = line.replace(/^#+\s*/, '').trim() continue } if (!theme) { const themeMatch = line.match(/^theme\s*:\s*(.+)$/i) if (themeMatch?.[1]) { theme = themeMatch[1].trim() continue } } if (!emoji) { const emojiMatch = line.match(/^emoji\s*:\s*(.+)$/i) if (emojiMatch?.[1]) { emoji = emojiMatch[1].trim() } } } return { ...(name ? { name } : {}), ...(theme ? { theme } : {}), ...(emoji ? { emoji } : {}), content: lines.slice(0, 8).join('\n'), } } function parseToolsFromFile(content: string): { allow?: string[]; raw?: string } { if (!content.trim()) return {} const parsedTools = new Set() for (const line of content.split('\n')) { const cleaned = line.trim() if (!cleaned || cleaned.startsWith('#')) continue const listMatch = cleaned.match(/^[-*]\s+`?([^`]+?)`?\s*$/) if (listMatch?.[1]) { parsedTools.add(listMatch[1].trim()) continue } const inlineMatch = cleaned.match(/^`([^`]+)`$/) if (inlineMatch?.[1]) { parsedTools.add(inlineMatch[1].trim()) } } const allow = [...parsedTools].filter(Boolean) return { ...(allow.length > 0 ? { allow } : {}), raw: content.split('\n').map((line) => line.trim()).filter(Boolean).slice(0, 24).join('\n'), } } function getConfigPath(): string | null { return config.openclawConfigPath || null } function resolveAgentWorkspacePath(workspace: string): string { if (isAbsolute(workspace)) return resolve(workspace) if (!config.openclawStateDir) { throw new Error('OPENCLAW_STATE_DIR not configured') } return resolveWithin(config.openclawStateDir, workspace) } const MAX_WORKSPACE_FILE_BYTES = 1024 * 1024 // 1 MB /** Safely read a file from an agent's workspace directory */ function readWorkspaceFile(workspace: string | undefined, filename: string): string | null { if (!workspace) return null try { const safeWorkspace = resolveAgentWorkspacePath(workspace) const safePath = resolveWithin(safeWorkspace, filename) if (existsSync(safePath)) { const size = statSync(safePath).size if (size > MAX_WORKSPACE_FILE_BYTES) { logger.warn({ workspace, filename, size }, `Workspace file exceeds ${MAX_WORKSPACE_FILE_BYTES} byte limit, skipping`) return null } return readFileSync(safePath, 'utf-8') } } catch (err) { logger.warn({ err, workspace, filename }, 'Failed to read workspace file') } return null } export function enrichAgentConfigFromWorkspace(configData: any): any { if (!configData || typeof configData !== 'object') return configData const workspace = typeof configData.workspace === 'string' ? configData.workspace : undefined if (!workspace) return configData const identityFile = readWorkspaceFile(workspace, 'identity.md') const toolsFile = readWorkspaceFile(workspace, 'TOOLS.md') const mergedIdentity = { ...parseIdentityFromFile(identityFile || ''), ...((configData.identity && typeof configData.identity === 'object') ? configData.identity : {}), } const mergedTools = { ...parseToolsFromFile(toolsFile || ''), ...((configData.tools && typeof configData.tools === 'object') ? configData.tools : {}), } return { ...configData, identity: Object.keys(mergedIdentity).length > 0 ? mergedIdentity : configData.identity, tools: Object.keys(mergedTools).length > 0 ? mergedTools : configData.tools, } } /** Read and parse openclaw.json agents list */ async function readOpenClawAgents(): Promise { const configPath = getConfigPath() if (!configPath) throw new Error('OPENCLAW_CONFIG_PATH not configured') const { readFile } = require('fs/promises') const raw = await readFile(configPath, 'utf-8') const parsed = parseJsonRelaxed(raw) return parsed?.agents?.list || [] } /** Extract MC-friendly fields from an OpenClaw agent config */ function mapAgentToMC(agent: OpenClawAgent): { name: string role: string config: any soul_content: string | null } { const name = agent.identity?.name || agent.name || agent.id const role = agent.identity?.theme || 'agent' // Store the full config minus systemPrompt/soul (which can be large) const configData = enrichAgentConfigFromWorkspace({ openclawId: agent.id, model: agent.model, identity: agent.identity, sandbox: agent.sandbox, tools: agent.tools, subagents: agent.subagents, memorySearch: agent.memorySearch, workspace: agent.workspace, agentDir: agent.agentDir, isDefault: agent.default || false, }) // Read soul.md from the agent's workspace if available const soul_content = readWorkspaceFile(agent.workspace, 'soul.md') return { name, role, config: configData, soul_content } } /** Sync agents from openclaw.json into the MC database */ export async function syncAgentsFromConfig(actor: string = 'system'): Promise { let agents: OpenClawAgent[] try { agents = await readOpenClawAgents() } catch (err: any) { return { synced: 0, created: 0, updated: 0, agents: [], error: err.message } } if (agents.length === 0) { return { synced: 0, created: 0, updated: 0, agents: [] } } const db = getDatabase() const now = Math.floor(Date.now() / 1000) let created = 0 let updated = 0 const results: SyncResult['agents'] = [] const findByName = db.prepare('SELECT id, name, role, config, soul_content FROM agents WHERE name = ?') const insertAgent = db.prepare(` INSERT INTO agents (name, role, soul_content, status, created_at, updated_at, config) VALUES (?, ?, ?, 'offline', ?, ?, ?) `) const updateAgent = db.prepare(` UPDATE agents SET role = ?, config = ?, soul_content = ?, updated_at = ? WHERE name = ? `) db.transaction(() => { for (const agent of agents) { const mapped = mapAgentToMC(agent) const configJson = JSON.stringify(mapped.config) const existing = findByName.get(mapped.name) as any if (existing) { // Check if config or soul_content actually changed const existingConfig = existing.config || '{}' const existingSoul = existing.soul_content || null const configChanged = existingConfig !== configJson || existing.role !== mapped.role const soulChanged = mapped.soul_content !== null && mapped.soul_content !== existingSoul if (configChanged || soulChanged) { // Only overwrite soul_content if we read a new value from workspace const soulToWrite = mapped.soul_content ?? existingSoul updateAgent.run(mapped.role, configJson, soulToWrite, now, mapped.name) results.push({ id: agent.id, name: mapped.name, action: 'updated' }) updated++ } else { results.push({ id: agent.id, name: mapped.name, action: 'unchanged' }) } } else { insertAgent.run(mapped.name, mapped.role, mapped.soul_content, now, now, configJson) results.push({ id: agent.id, name: mapped.name, action: 'created' }) created++ } } })() const synced = agents.length // Log audit event if (created > 0 || updated > 0) { logAuditEvent({ action: 'agent_config_sync', actor, detail: { synced, created, updated, agents: results.filter(a => a.action !== 'unchanged').map(a => a.name) }, }) // Broadcast sync event eventBus.broadcast('agent.created', { type: 'sync', synced, created, updated }) } logger.info({ synced, created, updated }, 'Agent sync complete') return { synced, created, updated, agents: results } } /** Preview the diff between openclaw.json and MC database without writing */ export async function previewSyncDiff(): Promise { let agents: OpenClawAgent[] try { agents = await readOpenClawAgents() } catch { return { inConfig: 0, inMC: 0, newAgents: [], updatedAgents: [], onlyInMC: [] } } const db = getDatabase() const allMCAgents = db.prepare('SELECT name, role, config FROM agents').all() as Array<{ name: string; role: string; config: string }> const mcNames = new Set(allMCAgents.map(a => a.name)) const newAgents: string[] = [] const updatedAgents: string[] = [] const configNames = new Set() for (const agent of agents) { const mapped = mapAgentToMC(agent) configNames.add(mapped.name) const existing = allMCAgents.find(a => a.name === mapped.name) if (!existing) { newAgents.push(mapped.name) } else { const configJson = JSON.stringify(mapped.config) if (existing.config !== configJson || existing.role !== mapped.role) { updatedAgents.push(mapped.name) } } } const onlyInMC = allMCAgents .map(a => a.name) .filter(name => !configNames.has(name)) return { inConfig: agents.length, inMC: allMCAgents.length, newAgents, updatedAgents, onlyInMC, } } /** Write an agent config back to openclaw.json agents.list */ export async function writeAgentToConfig(agentConfig: any): Promise { const configPath = getConfigPath() if (!configPath) throw new Error('OPENCLAW_CONFIG_PATH not configured') const { readFile, writeFile } = require('fs/promises') const raw = await readFile(configPath, 'utf-8') const parsed = parseJsonRelaxed(raw) if (!parsed.agents) parsed.agents = {} if (!parsed.agents.list) parsed.agents.list = [] const normalizedAgentConfig = normalizeAgentConfigForOpenClaw(agentConfig) // Find existing by id const idx = parsed.agents.list.findIndex((a: any) => a.id === normalizedAgentConfig.id) if (idx >= 0) { // Deep merge: preserve fields not in update parsed.agents.list[idx] = normalizeAgentConfigForOpenClaw( deepMerge(parsed.agents.list[idx], normalizedAgentConfig), ) } else { parsed.agents.list.push(normalizedAgentConfig) } await writeFile(configPath, JSON.stringify(parsed, null, 2) + '\n') } export async function removeAgentFromConfig(match: { id?: string | null name?: string | null }): Promise<{ removed: boolean }> { const configPath = getConfigPath() if (!configPath) throw new Error('OPENCLAW_CONFIG_PATH not configured') const id = String(match.id || '').trim() const name = String(match.name || '').trim() if (!id && !name) { return { removed: false } } const { readFile, writeFile } = require('fs/promises') const raw = await readFile(configPath, 'utf-8') const parsed = parseJsonRelaxed(raw) const existingList = Array.isArray(parsed?.agents?.list) ? parsed.agents.list : [] const nextList = existingList.filter((agent: any) => { const agentId = String(agent?.id || '').trim() const agentName = String(agent?.name || '').trim() const identityName = String(agent?.identity?.name || '').trim() if (id && agentId === id) return false if (name && (agentName === name || identityName === name)) return false return true }) if (nextList.length === existingList.length) { return { removed: false } } if (!parsed.agents) parsed.agents = {} parsed.agents.list = nextList await writeFile(configPath, JSON.stringify(parsed, null, 2) + '\n') return { removed: true } } /** Deep merge two objects (target <- source), preserving target fields not in source */ function deepMerge(target: any, source: any): any { const result = { ...target } for (const key of Object.keys(source)) { if ( source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key]) ) { result[key] = deepMerge(target[key], source[key]) } else { result[key] = source[key] } } return result } function normalizeModelConfig(model: unknown): unknown { if (!model || typeof model !== 'object' || Array.isArray(model)) return model const current = { ...(model as Record) } let primary = current.primary while (primary && typeof primary === 'object' && !Array.isArray(primary)) { const nestedPrimary = (primary as Record).primary if (typeof nestedPrimary !== 'string') break primary = nestedPrimary } const normalizedFallbacks = Array.isArray(current.fallbacks) ? [...new Set(current.fallbacks.map((value) => String(value || '').trim()).filter(Boolean))] : current.fallbacks return { ...current, ...(typeof primary === 'string' ? { primary } : {}), ...(Array.isArray(normalizedFallbacks) ? { fallbacks: normalizedFallbacks } : {}), } } function normalizeAgentConfigForOpenClaw(agentConfig: any): any { if (!agentConfig || typeof agentConfig !== 'object') return agentConfig if (!('model' in agentConfig)) return agentConfig return { ...agentConfig, model: normalizeModelConfig(agentConfig.model), } }