Spaces:
Sleeping
Sleeping
nyk commited on
fix: sync agent SOUL content with workspace files (#91) (#95)
Browse filesWorkspace 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.
- src/app/api/agents/[id]/soul/route.ts +56 -14
- src/lib/agent-sync.ts +38 -10
src/app/api/agents/[id]/soul/route.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { getDatabase, db_helpers } from '@/lib/db';
|
| 3 |
-
import { readFileSync, existsSync, readdirSync } from 'fs';
|
| 4 |
-
import { join } from 'path';
|
| 5 |
import { config } from '@/lib/config';
|
| 6 |
import { resolveWithin } from '@/lib/paths';
|
| 7 |
import { getUserFromRequest, requireRole } from '@/lib/auth';
|
|
@@ -34,9 +34,33 @@ export async function GET(
|
|
| 34 |
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
| 35 |
}
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
const templatesPath = config.soulTemplatesDir;
|
| 38 |
let availableTemplates: string[] = [];
|
| 39 |
-
|
| 40 |
try {
|
| 41 |
if (templatesPath && existsSync(templatesPath)) {
|
| 42 |
const files = readdirSync(templatesPath);
|
|
@@ -47,14 +71,15 @@ export async function GET(
|
|
| 47 |
} catch (error) {
|
| 48 |
logger.warn({ err: error }, 'Could not read soul templates directory');
|
| 49 |
}
|
| 50 |
-
|
| 51 |
return NextResponse.json({
|
| 52 |
agent: {
|
| 53 |
id: agent.id,
|
| 54 |
name: agent.name,
|
| 55 |
role: agent.role
|
| 56 |
},
|
| 57 |
-
soul_content:
|
|
|
|
| 58 |
available_templates: availableTemplates,
|
| 59 |
updated_at: agent.updated_at
|
| 60 |
});
|
|
@@ -125,34 +150,51 @@ export async function PUT(
|
|
| 125 |
}
|
| 126 |
|
| 127 |
const now = Math.floor(Date.now() / 1000);
|
| 128 |
-
|
| 129 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
const updateStmt = db.prepare(`
|
| 131 |
-
UPDATE agents
|
| 132 |
SET soul_content = ?, updated_at = ?
|
| 133 |
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
|
| 134 |
`);
|
| 135 |
-
|
| 136 |
updateStmt.run(newSoulContent, now, agentId);
|
| 137 |
-
|
| 138 |
// Log activity
|
| 139 |
db_helpers.logActivity(
|
| 140 |
'agent_soul_updated',
|
| 141 |
'agent',
|
| 142 |
agent.id,
|
| 143 |
getUserFromRequest(request)?.username || 'system',
|
| 144 |
-
`SOUL content updated for agent ${agent.name}${template_name ? ` using template: ${template_name}` : ''}`,
|
| 145 |
-
{
|
| 146 |
template_used: template_name || null,
|
| 147 |
content_length: newSoulContent ? newSoulContent.length : 0,
|
| 148 |
-
previous_content_length: agent.soul_content ? agent.soul_content.length : 0
|
|
|
|
| 149 |
}
|
| 150 |
);
|
| 151 |
-
|
| 152 |
return NextResponse.json({
|
| 153 |
success: true,
|
| 154 |
message: `SOUL content updated for ${agent.name}`,
|
| 155 |
soul_content: newSoulContent,
|
|
|
|
| 156 |
updated_at: now
|
| 157 |
});
|
| 158 |
} catch (error) {
|
|
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
import { getDatabase, db_helpers } from '@/lib/db';
|
| 3 |
+
import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'fs';
|
| 4 |
+
import { join, dirname } from 'path';
|
| 5 |
import { config } from '@/lib/config';
|
| 6 |
import { resolveWithin } from '@/lib/paths';
|
| 7 |
import { getUserFromRequest, requireRole } from '@/lib/auth';
|
|
|
|
| 34 |
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
| 35 |
}
|
| 36 |
|
| 37 |
+
// Try reading soul.md from workspace first, fall back to DB
|
| 38 |
+
let soulContent = ''
|
| 39 |
+
let source: 'workspace' | 'database' | 'none' = 'none'
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
const agentConfig = agent.config ? JSON.parse(agent.config) : {}
|
| 43 |
+
if (agentConfig.workspace && config.openclawHome) {
|
| 44 |
+
const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace)
|
| 45 |
+
const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
|
| 46 |
+
if (existsSync(safeSoulPath)) {
|
| 47 |
+
soulContent = readFileSync(safeSoulPath, 'utf-8')
|
| 48 |
+
source = 'workspace'
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
} catch (err) {
|
| 52 |
+
logger.warn({ err, agent: agent.name }, 'Failed to read soul.md from workspace')
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Fall back to database value
|
| 56 |
+
if (!soulContent && agent.soul_content) {
|
| 57 |
+
soulContent = agent.soul_content
|
| 58 |
+
source = 'database'
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
const templatesPath = config.soulTemplatesDir;
|
| 62 |
let availableTemplates: string[] = [];
|
| 63 |
+
|
| 64 |
try {
|
| 65 |
if (templatesPath && existsSync(templatesPath)) {
|
| 66 |
const files = readdirSync(templatesPath);
|
|
|
|
| 71 |
} catch (error) {
|
| 72 |
logger.warn({ err: error }, 'Could not read soul templates directory');
|
| 73 |
}
|
| 74 |
+
|
| 75 |
return NextResponse.json({
|
| 76 |
agent: {
|
| 77 |
id: agent.id,
|
| 78 |
name: agent.name,
|
| 79 |
role: agent.role
|
| 80 |
},
|
| 81 |
+
soul_content: soulContent,
|
| 82 |
+
source,
|
| 83 |
available_templates: availableTemplates,
|
| 84 |
updated_at: agent.updated_at
|
| 85 |
});
|
|
|
|
| 150 |
}
|
| 151 |
|
| 152 |
const now = Math.floor(Date.now() / 1000);
|
| 153 |
+
|
| 154 |
+
// Write to workspace file if available
|
| 155 |
+
let savedToWorkspace = false
|
| 156 |
+
try {
|
| 157 |
+
const agentConfig = agent.config ? JSON.parse(agent.config) : {}
|
| 158 |
+
if (agentConfig.workspace && config.openclawHome) {
|
| 159 |
+
const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace)
|
| 160 |
+
const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
|
| 161 |
+
mkdirSync(dirname(safeSoulPath), { recursive: true })
|
| 162 |
+
writeFileSync(safeSoulPath, newSoulContent || '', 'utf-8')
|
| 163 |
+
savedToWorkspace = true
|
| 164 |
+
}
|
| 165 |
+
} catch (err) {
|
| 166 |
+
logger.warn({ err, agent: agent.name }, 'Failed to write soul.md to workspace, saving to DB only')
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// Update SOUL content in DB
|
| 170 |
const updateStmt = db.prepare(`
|
| 171 |
+
UPDATE agents
|
| 172 |
SET soul_content = ?, updated_at = ?
|
| 173 |
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
|
| 174 |
`);
|
| 175 |
+
|
| 176 |
updateStmt.run(newSoulContent, now, agentId);
|
| 177 |
+
|
| 178 |
// Log activity
|
| 179 |
db_helpers.logActivity(
|
| 180 |
'agent_soul_updated',
|
| 181 |
'agent',
|
| 182 |
agent.id,
|
| 183 |
getUserFromRequest(request)?.username || 'system',
|
| 184 |
+
`SOUL content updated for agent ${agent.name}${template_name ? ` using template: ${template_name}` : ''}${savedToWorkspace ? ' (synced to workspace)' : ''}`,
|
| 185 |
+
{
|
| 186 |
template_used: template_name || null,
|
| 187 |
content_length: newSoulContent ? newSoulContent.length : 0,
|
| 188 |
+
previous_content_length: agent.soul_content ? agent.soul_content.length : 0,
|
| 189 |
+
saved_to_workspace: savedToWorkspace
|
| 190 |
}
|
| 191 |
);
|
| 192 |
+
|
| 193 |
return NextResponse.json({
|
| 194 |
success: true,
|
| 195 |
message: `SOUL content updated for ${agent.name}`,
|
| 196 |
soul_content: newSoulContent,
|
| 197 |
+
saved_to_workspace: savedToWorkspace,
|
| 198 |
updated_at: now
|
| 199 |
});
|
| 200 |
} catch (error) {
|
src/lib/agent-sync.ts
CHANGED
|
@@ -9,6 +9,9 @@ import { config } from './config'
|
|
| 9 |
import { getDatabase, db_helpers, logAuditEvent } from './db'
|
| 10 |
import { eventBus } from './event-bus'
|
| 11 |
import { join } from 'path'
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
interface OpenClawAgent {
|
| 14 |
id: string
|
|
@@ -64,6 +67,21 @@ function getConfigPath(): string | null {
|
|
| 64 |
return join(config.openclawHome, 'openclaw.json')
|
| 65 |
}
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
/** Read and parse openclaw.json agents list */
|
| 68 |
async function readOpenClawAgents(): Promise<OpenClawAgent[]> {
|
| 69 |
const configPath = getConfigPath()
|
|
@@ -80,6 +98,7 @@ function mapAgentToMC(agent: OpenClawAgent): {
|
|
| 80 |
name: string
|
| 81 |
role: string
|
| 82 |
config: any
|
|
|
|
| 83 |
} {
|
| 84 |
const name = agent.identity?.name || agent.name || agent.id
|
| 85 |
const role = agent.identity?.theme || 'agent'
|
|
@@ -98,7 +117,10 @@ function mapAgentToMC(agent: OpenClawAgent): {
|
|
| 98 |
isDefault: agent.default || false,
|
| 99 |
}
|
| 100 |
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
/** Sync agents from openclaw.json into the MC database */
|
|
@@ -120,13 +142,13 @@ export async function syncAgentsFromConfig(actor: string = 'system'): Promise<Sy
|
|
| 120 |
let updated = 0
|
| 121 |
const results: SyncResult['agents'] = []
|
| 122 |
|
| 123 |
-
const findByName = db.prepare('SELECT id, name, role, config FROM agents WHERE name = ?')
|
| 124 |
const insertAgent = db.prepare(`
|
| 125 |
-
INSERT INTO agents (name, role, status, created_at, updated_at, config)
|
| 126 |
-
VALUES (?, ?, 'offline', ?, ?, ?)
|
| 127 |
`)
|
| 128 |
const updateAgent = db.prepare(`
|
| 129 |
-
UPDATE agents SET role = ?, config = ?, updated_at = ? WHERE name = ?
|
| 130 |
`)
|
| 131 |
|
| 132 |
db.transaction(() => {
|
|
@@ -136,17 +158,23 @@ export async function syncAgentsFromConfig(actor: string = 'system'): Promise<Sy
|
|
| 136 |
const existing = findByName.get(mapped.name) as any
|
| 137 |
|
| 138 |
if (existing) {
|
| 139 |
-
// Check if config actually changed
|
| 140 |
const existingConfig = existing.config || '{}'
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
results.push({ id: agent.id, name: mapped.name, action: 'updated' })
|
| 144 |
updated++
|
| 145 |
} else {
|
| 146 |
results.push({ id: agent.id, name: mapped.name, action: 'unchanged' })
|
| 147 |
}
|
| 148 |
} else {
|
| 149 |
-
insertAgent.run(mapped.name, mapped.role, now, now, configJson)
|
| 150 |
results.push({ id: agent.id, name: mapped.name, action: 'created' })
|
| 151 |
created++
|
| 152 |
}
|
|
@@ -167,7 +195,7 @@ export async function syncAgentsFromConfig(actor: string = 'system'): Promise<Sy
|
|
| 167 |
eventBus.broadcast('agent.created', { type: 'sync', synced, created, updated })
|
| 168 |
}
|
| 169 |
|
| 170 |
-
|
| 171 |
return { synced, created, updated, agents: results }
|
| 172 |
}
|
| 173 |
|
|
|
|
| 9 |
import { getDatabase, db_helpers, logAuditEvent } from './db'
|
| 10 |
import { eventBus } from './event-bus'
|
| 11 |
import { join } from 'path'
|
| 12 |
+
import { existsSync, readFileSync } from 'fs'
|
| 13 |
+
import { resolveWithin } from './paths'
|
| 14 |
+
import { logger } from './logger'
|
| 15 |
|
| 16 |
interface OpenClawAgent {
|
| 17 |
id: string
|
|
|
|
| 67 |
return join(config.openclawHome, 'openclaw.json')
|
| 68 |
}
|
| 69 |
|
| 70 |
+
/** Safely read a file from an agent's workspace directory */
|
| 71 |
+
function readWorkspaceFile(workspace: string | undefined, filename: string): string | null {
|
| 72 |
+
if (!workspace || !config.openclawHome) return null
|
| 73 |
+
try {
|
| 74 |
+
const safeWorkspace = resolveWithin(config.openclawHome, workspace)
|
| 75 |
+
const safePath = resolveWithin(safeWorkspace, filename)
|
| 76 |
+
if (existsSync(safePath)) {
|
| 77 |
+
return readFileSync(safePath, 'utf-8')
|
| 78 |
+
}
|
| 79 |
+
} catch (err) {
|
| 80 |
+
logger.warn({ err, workspace, filename }, 'Failed to read workspace file')
|
| 81 |
+
}
|
| 82 |
+
return null
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
/** Read and parse openclaw.json agents list */
|
| 86 |
async function readOpenClawAgents(): Promise<OpenClawAgent[]> {
|
| 87 |
const configPath = getConfigPath()
|
|
|
|
| 98 |
name: string
|
| 99 |
role: string
|
| 100 |
config: any
|
| 101 |
+
soul_content: string | null
|
| 102 |
} {
|
| 103 |
const name = agent.identity?.name || agent.name || agent.id
|
| 104 |
const role = agent.identity?.theme || 'agent'
|
|
|
|
| 117 |
isDefault: agent.default || false,
|
| 118 |
}
|
| 119 |
|
| 120 |
+
// Read soul.md from the agent's workspace if available
|
| 121 |
+
const soul_content = readWorkspaceFile(agent.workspace, 'soul.md')
|
| 122 |
+
|
| 123 |
+
return { name, role, config: configData, soul_content }
|
| 124 |
}
|
| 125 |
|
| 126 |
/** Sync agents from openclaw.json into the MC database */
|
|
|
|
| 142 |
let updated = 0
|
| 143 |
const results: SyncResult['agents'] = []
|
| 144 |
|
| 145 |
+
const findByName = db.prepare('SELECT id, name, role, config, soul_content FROM agents WHERE name = ?')
|
| 146 |
const insertAgent = db.prepare(`
|
| 147 |
+
INSERT INTO agents (name, role, soul_content, status, created_at, updated_at, config)
|
| 148 |
+
VALUES (?, ?, ?, 'offline', ?, ?, ?)
|
| 149 |
`)
|
| 150 |
const updateAgent = db.prepare(`
|
| 151 |
+
UPDATE agents SET role = ?, config = ?, soul_content = ?, updated_at = ? WHERE name = ?
|
| 152 |
`)
|
| 153 |
|
| 154 |
db.transaction(() => {
|
|
|
|
| 158 |
const existing = findByName.get(mapped.name) as any
|
| 159 |
|
| 160 |
if (existing) {
|
| 161 |
+
// Check if config or soul_content actually changed
|
| 162 |
const existingConfig = existing.config || '{}'
|
| 163 |
+
const existingSoul = existing.soul_content || null
|
| 164 |
+
const configChanged = existingConfig !== configJson || existing.role !== mapped.role
|
| 165 |
+
const soulChanged = mapped.soul_content !== null && mapped.soul_content !== existingSoul
|
| 166 |
+
|
| 167 |
+
if (configChanged || soulChanged) {
|
| 168 |
+
// Only overwrite soul_content if we read a new value from workspace
|
| 169 |
+
const soulToWrite = mapped.soul_content ?? existingSoul
|
| 170 |
+
updateAgent.run(mapped.role, configJson, soulToWrite, now, mapped.name)
|
| 171 |
results.push({ id: agent.id, name: mapped.name, action: 'updated' })
|
| 172 |
updated++
|
| 173 |
} else {
|
| 174 |
results.push({ id: agent.id, name: mapped.name, action: 'unchanged' })
|
| 175 |
}
|
| 176 |
} else {
|
| 177 |
+
insertAgent.run(mapped.name, mapped.role, mapped.soul_content, now, now, configJson)
|
| 178 |
results.push({ id: agent.id, name: mapped.name, action: 'created' })
|
| 179 |
created++
|
| 180 |
}
|
|
|
|
| 195 |
eventBus.broadcast('agent.created', { type: 'sync', synced, created, updated })
|
| 196 |
}
|
| 197 |
|
| 198 |
+
logger.info({ synced, created, updated }, 'Agent sync complete')
|
| 199 |
return { synced, created, updated, agents: results }
|
| 200 |
}
|
| 201 |
|