fix: sync agent SOUL content with workspace files (#91) (#95)

Workspace file is now the primary source for soul.md with DB as
fallback. Reads prefer workspace → DB. Writes go to both. Config sync
imports soul.md from each agent's workspace using double resolveWithin
guard to prevent path traversal.
This commit is contained in:
nyk 2026-03-03 17:36:14 +07:00 committed by GitHub
parent 274b726df4
commit e6bae7ad88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 94 additions and 24 deletions

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getDatabase, db_helpers } from '@/lib/db'; import { getDatabase, db_helpers } from '@/lib/db';
import { readFileSync, existsSync, readdirSync } from 'fs'; import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path'; import { join, dirname } from 'path';
import { config } from '@/lib/config'; import { config } from '@/lib/config';
import { resolveWithin } from '@/lib/paths'; import { resolveWithin } from '@/lib/paths';
import { getUserFromRequest, requireRole } from '@/lib/auth'; import { getUserFromRequest, requireRole } from '@/lib/auth';
@ -34,6 +34,30 @@ export async function GET(
return NextResponse.json({ error: 'Agent not found' }, { status: 404 }); return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
} }
// Try reading soul.md from workspace first, fall back to DB
let soulContent = ''
let source: 'workspace' | 'database' | 'none' = 'none'
try {
const agentConfig = agent.config ? JSON.parse(agent.config) : {}
if (agentConfig.workspace && config.openclawHome) {
const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace)
const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
if (existsSync(safeSoulPath)) {
soulContent = readFileSync(safeSoulPath, 'utf-8')
source = 'workspace'
}
}
} catch (err) {
logger.warn({ err, agent: agent.name }, 'Failed to read soul.md from workspace')
}
// Fall back to database value
if (!soulContent && agent.soul_content) {
soulContent = agent.soul_content
source = 'database'
}
const templatesPath = config.soulTemplatesDir; const templatesPath = config.soulTemplatesDir;
let availableTemplates: string[] = []; let availableTemplates: string[] = [];
@ -54,7 +78,8 @@ export async function GET(
name: agent.name, name: agent.name,
role: agent.role role: agent.role
}, },
soul_content: agent.soul_content || '', soul_content: soulContent,
source,
available_templates: availableTemplates, available_templates: availableTemplates,
updated_at: agent.updated_at updated_at: agent.updated_at
}); });
@ -126,7 +151,22 @@ export async function PUT(
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
// Update SOUL content // Write to workspace file if available
let savedToWorkspace = false
try {
const agentConfig = agent.config ? JSON.parse(agent.config) : {}
if (agentConfig.workspace && config.openclawHome) {
const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace)
const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
mkdirSync(dirname(safeSoulPath), { recursive: true })
writeFileSync(safeSoulPath, newSoulContent || '', 'utf-8')
savedToWorkspace = true
}
} catch (err) {
logger.warn({ err, agent: agent.name }, 'Failed to write soul.md to workspace, saving to DB only')
}
// Update SOUL content in DB
const updateStmt = db.prepare(` const updateStmt = db.prepare(`
UPDATE agents UPDATE agents
SET soul_content = ?, updated_at = ? SET soul_content = ?, updated_at = ?
@ -141,11 +181,12 @@ export async function PUT(
'agent', 'agent',
agent.id, agent.id,
getUserFromRequest(request)?.username || 'system', getUserFromRequest(request)?.username || 'system',
`SOUL content updated for agent ${agent.name}${template_name ? ` using template: ${template_name}` : ''}`, `SOUL content updated for agent ${agent.name}${template_name ? ` using template: ${template_name}` : ''}${savedToWorkspace ? ' (synced to workspace)' : ''}`,
{ {
template_used: template_name || null, template_used: template_name || null,
content_length: newSoulContent ? newSoulContent.length : 0, content_length: newSoulContent ? newSoulContent.length : 0,
previous_content_length: agent.soul_content ? agent.soul_content.length : 0 previous_content_length: agent.soul_content ? agent.soul_content.length : 0,
saved_to_workspace: savedToWorkspace
} }
); );
@ -153,6 +194,7 @@ export async function PUT(
success: true, success: true,
message: `SOUL content updated for ${agent.name}`, message: `SOUL content updated for ${agent.name}`,
soul_content: newSoulContent, soul_content: newSoulContent,
saved_to_workspace: savedToWorkspace,
updated_at: now updated_at: now
}); });
} catch (error) { } catch (error) {

View File

@ -9,6 +9,9 @@ import { config } from './config'
import { getDatabase, db_helpers, logAuditEvent } from './db' import { getDatabase, db_helpers, logAuditEvent } from './db'
import { eventBus } from './event-bus' import { eventBus } from './event-bus'
import { join } from 'path' import { join } from 'path'
import { existsSync, readFileSync } from 'fs'
import { resolveWithin } from './paths'
import { logger } from './logger'
interface OpenClawAgent { interface OpenClawAgent {
id: string id: string
@ -64,6 +67,21 @@ function getConfigPath(): string | null {
return join(config.openclawHome, 'openclaw.json') return join(config.openclawHome, 'openclaw.json')
} }
/** Safely read a file from an agent's workspace directory */
function readWorkspaceFile(workspace: string | undefined, filename: string): string | null {
if (!workspace || !config.openclawHome) return null
try {
const safeWorkspace = resolveWithin(config.openclawHome, workspace)
const safePath = resolveWithin(safeWorkspace, filename)
if (existsSync(safePath)) {
return readFileSync(safePath, 'utf-8')
}
} catch (err) {
logger.warn({ err, workspace, filename }, 'Failed to read workspace file')
}
return null
}
/** Read and parse openclaw.json agents list */ /** Read and parse openclaw.json agents list */
async function readOpenClawAgents(): Promise<OpenClawAgent[]> { async function readOpenClawAgents(): Promise<OpenClawAgent[]> {
const configPath = getConfigPath() const configPath = getConfigPath()
@ -80,6 +98,7 @@ function mapAgentToMC(agent: OpenClawAgent): {
name: string name: string
role: string role: string
config: any config: any
soul_content: string | null
} { } {
const name = agent.identity?.name || agent.name || agent.id const name = agent.identity?.name || agent.name || agent.id
const role = agent.identity?.theme || 'agent' const role = agent.identity?.theme || 'agent'
@ -98,7 +117,10 @@ function mapAgentToMC(agent: OpenClawAgent): {
isDefault: agent.default || false, isDefault: agent.default || false,
} }
return { name, role, config: configData } // 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 */ /** Sync agents from openclaw.json into the MC database */
@ -120,13 +142,13 @@ export async function syncAgentsFromConfig(actor: string = 'system'): Promise<Sy
let updated = 0 let updated = 0
const results: SyncResult['agents'] = [] const results: SyncResult['agents'] = []
const findByName = db.prepare('SELECT id, name, role, config FROM agents WHERE name = ?') const findByName = db.prepare('SELECT id, name, role, config, soul_content FROM agents WHERE name = ?')
const insertAgent = db.prepare(` const insertAgent = db.prepare(`
INSERT INTO agents (name, role, status, created_at, updated_at, config) INSERT INTO agents (name, role, soul_content, status, created_at, updated_at, config)
VALUES (?, ?, 'offline', ?, ?, ?) VALUES (?, ?, ?, 'offline', ?, ?, ?)
`) `)
const updateAgent = db.prepare(` const updateAgent = db.prepare(`
UPDATE agents SET role = ?, config = ?, updated_at = ? WHERE name = ? UPDATE agents SET role = ?, config = ?, soul_content = ?, updated_at = ? WHERE name = ?
`) `)
db.transaction(() => { db.transaction(() => {
@ -136,17 +158,23 @@ export async function syncAgentsFromConfig(actor: string = 'system'): Promise<Sy
const existing = findByName.get(mapped.name) as any const existing = findByName.get(mapped.name) as any
if (existing) { if (existing) {
// Check if config actually changed // Check if config or soul_content actually changed
const existingConfig = existing.config || '{}' const existingConfig = existing.config || '{}'
if (existingConfig !== configJson || existing.role !== mapped.role) { const existingSoul = existing.soul_content || null
updateAgent.run(mapped.role, configJson, now, mapped.name) 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' }) results.push({ id: agent.id, name: mapped.name, action: 'updated' })
updated++ updated++
} else { } else {
results.push({ id: agent.id, name: mapped.name, action: 'unchanged' }) results.push({ id: agent.id, name: mapped.name, action: 'unchanged' })
} }
} else { } else {
insertAgent.run(mapped.name, mapped.role, now, now, configJson) insertAgent.run(mapped.name, mapped.role, mapped.soul_content, now, now, configJson)
results.push({ id: agent.id, name: mapped.name, action: 'created' }) results.push({ id: agent.id, name: mapped.name, action: 'created' })
created++ created++
} }
@ -167,7 +195,7 @@ export async function syncAgentsFromConfig(actor: string = 'system'): Promise<Sy
eventBus.broadcast('agent.created', { type: 'sync', synced, created, updated }) eventBus.broadcast('agent.created', { type: 'sync', synced, created, updated })
} }
console.log(`Agent sync: ${synced} total, ${created} new, ${updated} updated`) logger.info({ synced, created, updated }, 'Agent sync complete')
return { synced, created, updated, agents: results } return { synced, created, updated, agents: results }
} }