openclaw-node-harness 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/bin/lane-watchdog.js +54 -23
  2. package/bin/mesh-agent.js +49 -18
  3. package/bin/mesh-bridge.js +3 -2
  4. package/bin/mesh-deploy.js +4 -0
  5. package/bin/mesh-health-publisher.js +41 -1
  6. package/bin/mesh-task-daemon.js +14 -4
  7. package/bin/mesh.js +17 -43
  8. package/install.sh +3 -2
  9. package/lib/agent-activity.js +2 -2
  10. package/lib/exec-safety.js +163 -0
  11. package/lib/kanban-io.js +20 -33
  12. package/lib/llm-providers.js +27 -0
  13. package/lib/mcp-knowledge/core.mjs +7 -5
  14. package/lib/mcp-knowledge/server.mjs +8 -1
  15. package/lib/mesh-collab.js +274 -250
  16. package/lib/mesh-harness.js +6 -0
  17. package/lib/mesh-plans.js +84 -45
  18. package/lib/mesh-tasks.js +113 -81
  19. package/lib/nats-resolve.js +4 -4
  20. package/lib/pre-compression-flush.mjs +2 -0
  21. package/lib/session-store.mjs +6 -3
  22. package/mission-control/package-lock.json +4188 -3698
  23. package/mission-control/package.json +2 -2
  24. package/mission-control/src/app/api/diagnostics/route.ts +8 -0
  25. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +8 -0
  26. package/mission-control/src/app/api/memory/graph/route.ts +34 -18
  27. package/mission-control/src/app/api/memory/search/route.ts +9 -5
  28. package/mission-control/src/app/api/mesh/identity/route.ts +13 -5
  29. package/mission-control/src/app/api/mesh/nodes/route.ts +8 -0
  30. package/mission-control/src/app/api/settings/gateway/route.ts +62 -0
  31. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +49 -12
  32. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  33. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +24 -5
  34. package/mission-control/src/app/api/souls/route.ts +6 -4
  35. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +7 -1
  36. package/mission-control/src/app/api/tasks/[id]/route.ts +20 -4
  37. package/mission-control/src/app/api/tasks/route.ts +68 -9
  38. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  39. package/mission-control/src/lib/config.ts +11 -2
  40. package/mission-control/src/lib/db/index.ts +16 -1
  41. package/mission-control/src/lib/memory/extract.ts +2 -1
  42. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  43. package/mission-control/src/lib/sync/tasks.ts +4 -1
  44. package/mission-control/src/middleware.ts +82 -0
  45. package/package.json +1 -1
  46. package/services/launchd/ai.openclaw.lane-watchdog.plist +1 -1
  47. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  48. package/services/launchd/ai.openclaw.mesh-agent.plist +4 -0
  49. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  50. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  51. package/services/launchd/ai.openclaw.mission-control.plist +5 -4
  52. package/uninstall.sh +37 -9
@@ -26,7 +26,7 @@
26
26
  "gray-matter": "^4.0.3",
27
27
  "lucide-react": "^0.575.0",
28
28
  "nats": "^2.29.3",
29
- "next": "16.1.6",
29
+ "next": "^16.2.1",
30
30
  "react": "19.2.3",
31
31
  "react-dom": "19.2.3",
32
32
  "react-force-graph-2d": "^1.29.1",
@@ -43,7 +43,7 @@
43
43
  "@types/node": "^20",
44
44
  "@types/react": "^19",
45
45
  "@types/react-dom": "^19",
46
- "drizzle-kit": "^0.31.9",
46
+ "drizzle-kit": "^0.31.10",
47
47
  "eslint": "^9",
48
48
  "eslint-config-next": "16.1.6",
49
49
  "tailwindcss": "^4.2.1",
@@ -8,6 +8,7 @@ import { parseTasksMarkdown, serializeTasksMarkdown } from "@/lib/parsers/task-m
8
8
  export const dynamic = "force-dynamic";
9
9
 
10
10
  export async function GET() {
11
+ try {
11
12
  const raw = getRawDb();
12
13
 
13
14
  // Task stats
@@ -94,4 +95,11 @@ export async function GET() {
94
95
  nats: natsStatus,
95
96
  workspace: workspaceExists,
96
97
  });
98
+ } catch (err) {
99
+ console.error("[diagnostics] error:", err);
100
+ return NextResponse.json(
101
+ { error: err instanceof Error ? err.message : "Internal server error" },
102
+ { status: err instanceof SyntaxError ? 400 : 500 }
103
+ );
104
+ }
97
105
  }
@@ -41,6 +41,7 @@ async function runTest(suite: string, name: string, fn: TestFn): Promise<TestRes
41
41
  * All test tasks use the prefix __TEST__ so they can be safely cleaned up.
42
42
  */
43
43
  export async function POST() {
44
+ try {
44
45
  const results: TestResult[] = [];
45
46
  const db = getDb();
46
47
  const raw = getRawDb();
@@ -987,4 +988,11 @@ export async function POST() {
987
988
  results,
988
989
  timestamp: new Date().toISOString(),
989
990
  });
991
+ } catch (err) {
992
+ console.error("[diagnostics/test-runner] error:", err);
993
+ return NextResponse.json(
994
+ { error: err instanceof Error ? err.message : "Internal server error" },
995
+ { status: err instanceof SyntaxError ? 400 : 500 }
996
+ );
997
+ }
990
998
  }
@@ -18,31 +18,47 @@ import {
18
18
  import { getRawDb } from "@/lib/db";
19
19
 
20
20
  export async function GET(request: Request) {
21
- const url = new URL(request.url);
22
- const boot = url.searchParams.get("boot");
23
- const format = url.searchParams.get("format");
21
+ try {
22
+ const url = new URL(request.url);
23
+ const boot = url.searchParams.get("boot");
24
+ const format = url.searchParams.get("format");
24
25
 
25
- if (boot === "true") {
26
- const block = formatEntityContextBlock();
27
- return NextResponse.json({ block });
28
- }
26
+ if (boot === "true") {
27
+ const block = formatEntityContextBlock();
28
+ return NextResponse.json({ block });
29
+ }
29
30
 
30
- if (format === "viz") {
31
- return getVizData();
32
- }
31
+ if (format === "viz") {
32
+ return getVizData();
33
+ }
33
34
 
34
- const stats = getGraphStats();
35
- const topEntities = getTopEntities(10).map((entity) => {
36
- const relations = getEntityRelations(entity.id, 3);
37
- return { ...entity, relations };
38
- });
35
+ const stats = getGraphStats();
36
+ const topEntities = getTopEntities(10).map((entity) => {
37
+ const relations = getEntityRelations(entity.id, 3);
38
+ return { ...entity, relations };
39
+ });
39
40
 
40
- return NextResponse.json({ stats, topEntities });
41
+ return NextResponse.json({ stats, topEntities });
42
+ } catch (err) {
43
+ console.error("[memory/graph] GET error:", err);
44
+ return NextResponse.json(
45
+ { error: err instanceof Error ? err.message : "Internal server error" },
46
+ { status: err instanceof SyntaxError ? 400 : 500 }
47
+ );
48
+ }
41
49
  }
42
50
 
43
51
  export async function POST() {
44
- const seeded = seedKnownEntities();
45
- return NextResponse.json({ seeded, message: `${seeded} entities seeded` });
52
+ try {
53
+ const seeded = seedKnownEntities();
54
+ return NextResponse.json({ seeded, message: `${seeded} entities seeded` });
55
+ } catch (err) {
56
+ console.error("[memory/graph] POST error:", err);
57
+ return NextResponse.json(
58
+ { error: err instanceof Error ? err.message : "Internal server error" },
59
+ { status: err instanceof SyntaxError ? 400 : 500 }
60
+ );
61
+ }
46
62
  }
47
63
 
48
64
  /**
@@ -29,14 +29,17 @@ function applyScoreDecay(
29
29
  * Daedalus does deeper semantic rewriting inline during his own searches.
30
30
  */
31
31
  function expandQuery(query: string): string {
32
+ // Escape double quotes and strip FTS5 operators to prevent query injection
33
+ const safeQuery = query.replace(/"/g, '""').replace(/[*(){}^]/g, '').trim();
34
+ if (!safeQuery) return '""';
32
35
  // Split into terms and add OR variants for common patterns
33
- const terms = query.trim().split(/\s+/);
36
+ const terms = safeQuery.split(/\s+/);
34
37
  if (terms.length === 1) {
35
38
  // Single term: use prefix matching
36
- return `"${query.replace(/"/g, '""')}"*`;
39
+ return `"${safeQuery}"*`;
37
40
  }
38
41
  // Multi-term: quote as phrase + add individual terms with OR for broader recall
39
- const phrase = `"${query.replace(/"/g, '""')}"`;
42
+ const phrase = `"${safeQuery}"`;
40
43
  const individual = terms.map((t) => `"${t.replace(/"/g, '""')}"*`).join(" OR ");
41
44
  return `(${phrase}) OR (${individual})`;
42
45
  }
@@ -54,7 +57,7 @@ export async function GET(request: NextRequest) {
54
57
  const source = searchParams.get("source");
55
58
  const category = searchParams.get("category");
56
59
  const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 100);
57
- const offset = parseInt(searchParams.get("offset") || "0", 10);
60
+ const offset = Math.max(0, Math.min(parseInt(searchParams.get("offset") || "0", 10) || 0, 10000));
58
61
  const useDecay = searchParams.get("decay") !== "false"; // default: true
59
62
  const decayRate = parseFloat(searchParams.get("decay_rate") || "0.01");
60
63
 
@@ -138,6 +141,7 @@ export async function GET(request: NextRequest) {
138
141
  console.error("GET /api/memory/search error:", err);
139
142
  const message =
140
143
  err instanceof Error ? err.message : "Failed to search memory";
141
- return NextResponse.json({ error: message }, { status: 500 });
144
+ const status = err instanceof SyntaxError || err instanceof RangeError ? 400 : 500;
145
+ return NextResponse.json({ error: message }, { status });
142
146
  }
143
147
  }
@@ -3,9 +3,17 @@ import { NODE_ID, NODE_ROLE, NODE_PLATFORM } from "@/lib/config";
3
3
  export const dynamic = "force-dynamic";
4
4
 
5
5
  export async function GET() {
6
- return Response.json({
7
- nodeId: NODE_ID,
8
- role: NODE_ROLE,
9
- platform: NODE_PLATFORM,
10
- });
6
+ try {
7
+ return Response.json({
8
+ nodeId: NODE_ID,
9
+ role: NODE_ROLE,
10
+ platform: NODE_PLATFORM,
11
+ });
12
+ } catch (err) {
13
+ console.error("[mesh/identity] error:", err);
14
+ return Response.json(
15
+ { error: err instanceof Error ? err.message : "Internal server error" },
16
+ { status: 500 }
17
+ );
18
+ }
11
19
  }
@@ -85,6 +85,7 @@ const KNOWN_NODES = [
85
85
  * No synchronous round-trips to nodes. No timeout races. No flickering.
86
86
  */
87
87
  export async function GET() {
88
+ try {
88
89
  const kv = await getHealthKv();
89
90
  const db = getDb();
90
91
  const now = Date.now();
@@ -218,4 +219,11 @@ export async function GET() {
218
219
  }
219
220
 
220
221
  return NextResponse.json({ nodes, tokenStats });
222
+ } catch (err) {
223
+ console.error("[mesh/nodes] error:", err);
224
+ return NextResponse.json(
225
+ { error: err instanceof Error ? err.message : "Internal server error" },
226
+ { status: err instanceof SyntaxError ? 400 : 500 }
227
+ );
228
+ }
221
229
  }
@@ -52,9 +52,63 @@ export async function GET() {
52
52
  * Update gateway-related settings. Accepts partial updates.
53
53
  * Body: { heartbeat?: { target, every? }, maxConcurrent?: number }
54
54
  */
55
+ const ALLOWED_TOP_LEVEL_KEYS = new Set([
56
+ "heartbeat", "maxConcurrent", "compaction", "gateway",
57
+ ]);
58
+ const ALLOWED_GATEWAY_KEYS = new Set(["port", "mode", "bind"]);
59
+ const VALID_MODES = new Set(["local", "remote", "hybrid"]);
60
+ const VALID_BINDS = new Set(["loopback", "tailscale", "any"]);
61
+
55
62
  export async function PATCH(request: NextRequest) {
56
63
  try {
57
64
  const body = await request.json();
65
+
66
+ // Reject unknown top-level keys
67
+ const unknownKeys = Object.keys(body).filter((k) => !ALLOWED_TOP_LEVEL_KEYS.has(k));
68
+ if (unknownKeys.length > 0) {
69
+ return NextResponse.json(
70
+ { error: `Unknown keys: ${unknownKeys.join(", ")}` },
71
+ { status: 400 }
72
+ );
73
+ }
74
+
75
+ // Validate gateway sub-object if present
76
+ if (body.gateway !== undefined) {
77
+ if (typeof body.gateway !== "object" || body.gateway === null || Array.isArray(body.gateway)) {
78
+ return NextResponse.json(
79
+ { error: "gateway must be an object" },
80
+ { status: 400 }
81
+ );
82
+ }
83
+ const unknownGw = Object.keys(body.gateway).filter((k) => !ALLOWED_GATEWAY_KEYS.has(k));
84
+ if (unknownGw.length > 0) {
85
+ return NextResponse.json(
86
+ { error: `Unknown gateway keys: ${unknownGw.join(", ")}` },
87
+ { status: 400 }
88
+ );
89
+ }
90
+ if (body.gateway.port !== undefined) {
91
+ if (!Number.isInteger(body.gateway.port) || body.gateway.port < 1 || body.gateway.port > 65535) {
92
+ return NextResponse.json(
93
+ { error: "gateway.port must be an integer between 1 and 65535" },
94
+ { status: 400 }
95
+ );
96
+ }
97
+ }
98
+ if (body.gateway.mode !== undefined && !VALID_MODES.has(body.gateway.mode)) {
99
+ return NextResponse.json(
100
+ { error: `gateway.mode must be one of: ${[...VALID_MODES].join(", ")}` },
101
+ { status: 400 }
102
+ );
103
+ }
104
+ if (body.gateway.bind !== undefined && !VALID_BINDS.has(body.gateway.bind)) {
105
+ return NextResponse.json(
106
+ { error: `gateway.bind must be one of: ${[...VALID_BINDS].join(", ")}` },
107
+ { status: 400 }
108
+ );
109
+ }
110
+ }
111
+
58
112
  const config = await loadConfig();
59
113
 
60
114
  if (!config.agents) config.agents = {};
@@ -79,6 +133,14 @@ export async function PATCH(request: NextRequest) {
79
133
  config.agents.defaults.compaction = body.compaction;
80
134
  }
81
135
 
136
+ // Gateway settings
137
+ if (body.gateway !== undefined) {
138
+ if (!config.gateway) config.gateway = {};
139
+ if (body.gateway.port !== undefined) config.gateway.port = body.gateway.port;
140
+ if (body.gateway.mode !== undefined) config.gateway.mode = body.gateway.mode;
141
+ if (body.gateway.bind !== undefined) config.gateway.bind = body.gateway.bind;
142
+ }
143
+
82
144
  await saveConfig(config);
83
145
 
84
146
  return NextResponse.json({ ok: true });
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { getDb } from "@/lib/db";
3
+ import { validatePathParam } from "@/lib/config";
3
4
  import { soulEvolutionLog } from "@/lib/db/schema";
4
5
  import { eq, desc } from "drizzle-orm";
5
6
  import fs from "fs/promises";
@@ -31,7 +32,12 @@ export async function GET(
31
32
  { params }: { params: Promise<{ id: string }> }
32
33
  ) {
33
34
  try {
34
- const { id: soulId } = await params;
35
+ let soulId: string;
36
+ try {
37
+ soulId = validatePathParam((await params).id);
38
+ } catch {
39
+ return NextResponse.json({ error: "Invalid soul ID" }, { status: 400 });
40
+ }
35
41
  const { searchParams } = new URL(request.url);
36
42
  const status = searchParams.get("status") || "pending";
37
43
 
@@ -63,7 +69,10 @@ export async function GET(
63
69
  const lines = content.trim().split("\n").filter(Boolean);
64
70
  fullEvents = lines.map((line) => JSON.parse(line));
65
71
  } catch (error) {
66
- // events.jsonl might be empty or not exist yet
72
+ // events.jsonl might be empty, not exist yet, or contain malformed lines
73
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
74
+ console.warn("Failed to parse events.jsonl:", error);
75
+ }
67
76
  }
68
77
 
69
78
  // Merge DB records with full event details
@@ -93,7 +102,12 @@ export async function POST(
93
102
  { params }: { params: Promise<{ id: string }> }
94
103
  ) {
95
104
  try {
96
- const { id: soulId } = await params;
105
+ let soulId: string;
106
+ try {
107
+ soulId = validatePathParam((await params).id);
108
+ } catch {
109
+ return NextResponse.json({ error: "Invalid soul ID" }, { status: 400 });
110
+ }
97
111
  const event: EvolutionEvent = await request.json();
98
112
 
99
113
  const db = getDb();
@@ -133,7 +147,12 @@ export async function PATCH(
133
147
  { params }: { params: Promise<{ id: string }> }
134
148
  ) {
135
149
  try {
136
- const { id: soulId } = await params;
150
+ let soulId: string;
151
+ try {
152
+ soulId = validatePathParam((await params).id);
153
+ } catch {
154
+ return NextResponse.json({ error: "Invalid soul ID" }, { status: 400 });
155
+ }
137
156
  const { searchParams } = new URL(request.url);
138
157
  const eventId = searchParams.get("eventId");
139
158
 
@@ -158,11 +177,20 @@ export async function PATCH(
158
177
  "events.jsonl"
159
178
  );
160
179
  const content = await fs.readFile(eventsPath, "utf-8");
161
- const events = content
162
- .trim()
163
- .split("\n")
164
- .filter(Boolean)
165
- .map((line) => JSON.parse(line));
180
+ let events: EvolutionEvent[];
181
+ try {
182
+ events = content
183
+ .trim()
184
+ .split("\n")
185
+ .filter(Boolean)
186
+ .map((line) => JSON.parse(line));
187
+ } catch (parseErr) {
188
+ console.error("Malformed JSONL in events.jsonl:", parseErr);
189
+ return NextResponse.json(
190
+ { error: "Failed to parse evolution events file (malformed JSONL)" },
191
+ { status: 500 }
192
+ );
193
+ }
166
194
  const event = events.find((e: EvolutionEvent) => e.eventId === eventId);
167
195
 
168
196
  if (!event) {
@@ -170,15 +198,25 @@ export async function PATCH(
170
198
  }
171
199
 
172
200
  // Apply change (e.g., update genes.json)
201
+ const safeTarget = validatePathParam(event.proposedChange.target);
173
202
  const targetPath = path.join(
174
203
  SOULS_DIR,
175
204
  soulId,
176
205
  "evolution",
177
- event.proposedChange.target
206
+ safeTarget
178
207
  );
179
208
 
180
209
  if (event.proposedChange.action === "add") {
181
- const existing = JSON.parse(await fs.readFile(targetPath, "utf-8"));
210
+ let existing;
211
+ try {
212
+ existing = JSON.parse(await fs.readFile(targetPath, "utf-8"));
213
+ } catch (parseErr) {
214
+ console.error(`Malformed JSON in ${targetPath}:`, parseErr);
215
+ return NextResponse.json(
216
+ { error: `Failed to parse ${event.proposedChange.target} (malformed JSON)` },
217
+ { status: 500 }
218
+ );
219
+ }
182
220
  if (event.proposedChange.target === "genes.json") {
183
221
  existing.genes.push(event.proposedChange.content);
184
222
  }
@@ -189,7 +227,6 @@ export async function PATCH(
189
227
  // Sanitize all user-derived inputs: eventId, soulId, reviewedBy, event.summary
190
228
  // could all contain shell metacharacters if crafted maliciously.
191
229
  const safeBranch = `evolution/${eventId.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
192
- const safeTarget = event.proposedChange.target.replace(/[^a-zA-Z0-9._/-]/g, "_");
193
230
  const commitMessage = [
194
231
  `evolution(${eventId}): ${event.summary}`,
195
232
  "",
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { getDb } from "@/lib/db";
3
+ import { validatePathParam } from "@/lib/config";
3
4
  import { soulSpawns } from "@/lib/db/schema";
4
5
  import fs from "fs/promises";
5
6
  import path from "path";
@@ -38,7 +39,12 @@ export async function POST(
38
39
  { params }: { params: Promise<{ id: string }> }
39
40
  ) {
40
41
  try {
41
- const { id: soulId } = await params;
42
+ let soulId: string;
43
+ try {
44
+ soulId = validatePathParam((await params).id);
45
+ } catch {
46
+ return NextResponse.json({ error: "Invalid soul ID" }, { status: 400 });
47
+ }
42
48
  const body: PromptRequest = await request.json();
43
49
 
44
50
  const soulDir = path.join(SOULS_DIR, soulId);
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { getDb } from "@/lib/db";
3
+ import { validatePathParam } from "@/lib/config";
3
4
  import { soulEvolutionLog } from "@/lib/db/schema";
4
5
  import { eq } from "drizzle-orm";
5
6
  import fs from "fs/promises";
@@ -19,9 +20,20 @@ export async function POST(
19
20
  { params }: { params: Promise<{ id: string }> }
20
21
  ) {
21
22
  try {
22
- const { id: sourceSoulId } = await params;
23
+ let sourceSoulId: string;
24
+ try {
25
+ sourceSoulId = validatePathParam((await params).id);
26
+ } catch {
27
+ return NextResponse.json({ error: "Invalid source soul ID" }, { status: 400 });
28
+ }
23
29
  const body: PropagateRequest = await request.json();
24
- const { sourceEventId, targetSoulId } = body;
30
+ let targetSoulId: string;
31
+ try {
32
+ targetSoulId = validatePathParam(body.targetSoulId);
33
+ } catch {
34
+ return NextResponse.json({ error: "Invalid target soul ID" }, { status: 400 });
35
+ }
36
+ const { sourceEventId } = body;
25
37
 
26
38
  // Validate target soul exists
27
39
  const targetSoulDir = path.join(SOULS_DIR, targetSoulId);
@@ -63,10 +75,17 @@ export async function POST(
63
75
  const lines = content.trim().split("\n").filter(Boolean);
64
76
  const events: EvolutionEventEntry[] = lines.map((line) => JSON.parse(line));
65
77
  sourceEvent = events.find((e) => e.eventId === sourceEventId) ?? null;
66
- } catch {
78
+ } catch (error) {
79
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
80
+ return NextResponse.json(
81
+ { error: "Source events file not found" },
82
+ { status: 404 }
83
+ );
84
+ }
85
+ console.error("Failed to parse source events.jsonl:", error);
67
86
  return NextResponse.json(
68
- { error: "Source events file not found" },
69
- { status: 404 }
87
+ { error: "Failed to parse source events file (malformed JSONL)" },
88
+ { status: 500 }
70
89
  );
71
90
  }
72
91
 
@@ -94,9 +94,10 @@ export async function POST(request: NextRequest) {
94
94
  return NextResponse.json(newSoul, { status: 201 });
95
95
  } catch (error) {
96
96
  console.error("Failed to register soul:", error);
97
+ const status = error instanceof SyntaxError ? 400 : 500;
97
98
  return NextResponse.json(
98
- { error: "Failed to register soul" },
99
- { status: 500 }
99
+ { error: status === 400 ? String(error) : "Failed to register soul" },
100
+ { status }
100
101
  );
101
102
  }
102
103
  }
@@ -133,9 +134,10 @@ export async function PATCH(request: NextRequest) {
133
134
  return NextResponse.json(registry.souls[soulIndex]);
134
135
  } catch (error) {
135
136
  console.error("Failed to update soul:", error);
137
+ const status = error instanceof SyntaxError ? 400 : 500;
136
138
  return NextResponse.json(
137
- { error: "Failed to update soul" },
138
- { status: 500 }
139
+ { error: status === 400 ? String(error) : "Failed to update soul" },
140
+ { status }
139
141
  );
140
142
  }
141
143
  }
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { getDb } from "@/lib/db";
3
+ import { validatePathParam } from "@/lib/config";
3
4
  import { tasks, soulHandoffs } from "@/lib/db/schema";
4
5
  import { eq } from "drizzle-orm";
5
6
  import fs from "fs/promises";
@@ -27,7 +28,12 @@ export async function POST(
27
28
  { params }: { params: Promise<{ id: string }> }
28
29
  ) {
29
30
  try {
30
- const { id: taskId } = await params;
31
+ let taskId: string;
32
+ try {
33
+ taskId = validatePathParam((await params).id);
34
+ } catch {
35
+ return NextResponse.json({ error: "Invalid task ID" }, { status: 400 });
36
+ }
31
37
  const body: HandoffRequest = await request.json();
32
38
 
33
39
  const db = getDb();
@@ -9,6 +9,12 @@ import { gatewayNotify } from "@/lib/gateway-notify";
9
9
  import { AGENT_NAME, HUMAN_NAME } from "@/lib/config";
10
10
  import { getNats, sc } from "@/lib/nats";
11
11
 
12
+ /** Safely parse a JSON string from a DB field, returning fallback on failure. */
13
+ function safeParse(json: string | null, fallback: unknown = []): unknown {
14
+ if (!json) return fallback;
15
+ try { return JSON.parse(json); } catch { return fallback; }
16
+ }
17
+
12
18
  /**
13
19
  * Push a notification message to the OpenClaw TUI via gateway chat.send + abort.
14
20
  * Fire-and-forget — does not block the API response.
@@ -130,6 +136,18 @@ export async function PATCH(
130
136
  const body = await request.json();
131
137
  const now = new Date().toISOString();
132
138
 
139
+ // Status validation
140
+ const VALID_STATUSES = new Set([
141
+ "not started", "queued", "ready", "submitted", "running",
142
+ "blocked", "waiting-user", "done", "cancelled", "archived",
143
+ ]);
144
+ if (body.status && !VALID_STATUSES.has(body.status)) {
145
+ return NextResponse.json(
146
+ { error: `Invalid status: ${body.status}` },
147
+ { status: 400 }
148
+ );
149
+ }
150
+
133
151
  // Transition guard: block execution mode changes on in-flight mesh tasks
134
152
  if (body.execution !== undefined && body.execution !== existing.execution) {
135
153
  if (existing.meshTaskId) {
@@ -337,10 +355,8 @@ export async function PATCH(
337
355
 
338
356
  return NextResponse.json({
339
357
  ...updated,
340
- successCriteria: updated?.successCriteria
341
- ? JSON.parse(updated.successCriteria)
342
- : [],
343
- artifacts: updated?.artifacts ? JSON.parse(updated.artifacts) : [],
358
+ successCriteria: safeParse(updated?.successCriteria ?? null),
359
+ artifacts: safeParse(updated?.artifacts ?? null),
344
360
  });
345
361
  } catch (err) {
346
362
  console.error("PATCH /api/tasks/[id] error:", err);