nyk commited on
Commit
beb89d9
·
unverified ·
1 Parent(s): 1b11b46

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

Browse files

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.

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: agent.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
- // Update SOUL content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return { name, role, config: configData }
 
 
 
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
- if (existingConfig !== configJson || existing.role !== mapped.role) {
142
- updateAgent.run(mapped.role, configJson, now, mapped.name)
 
 
 
 
 
 
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
- console.log(`Agent sync: ${synced} total, ${created} new, ${updated} updated`)
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