openclaw-node-harness 2.1.0 → 2.1.1

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 (36) hide show
  1. package/bin/lane-watchdog.js +23 -2
  2. package/bin/mesh-agent.js +38 -16
  3. package/bin/mesh-bridge.js +3 -2
  4. package/bin/mesh-health-publisher.js +41 -1
  5. package/bin/mesh-task-daemon.js +5 -0
  6. package/bin/mesh.js +8 -19
  7. package/install.sh +3 -2
  8. package/lib/agent-activity.js +2 -2
  9. package/lib/exec-safety.js +105 -0
  10. package/lib/kanban-io.js +15 -31
  11. package/lib/llm-providers.js +16 -0
  12. package/lib/mcp-knowledge/core.mjs +7 -5
  13. package/lib/mcp-knowledge/server.mjs +8 -1
  14. package/lib/mesh-collab.js +268 -250
  15. package/lib/mesh-plans.js +66 -45
  16. package/lib/mesh-tasks.js +89 -73
  17. package/lib/nats-resolve.js +4 -4
  18. package/lib/pre-compression-flush.mjs +2 -0
  19. package/lib/session-store.mjs +6 -3
  20. package/mission-control/src/app/api/memory/search/route.ts +6 -3
  21. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
  22. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  23. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
  24. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +7 -1
  25. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  26. package/mission-control/src/lib/config.ts +9 -0
  27. package/mission-control/src/lib/db/index.ts +16 -1
  28. package/mission-control/src/lib/memory/extract.ts +2 -1
  29. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  30. package/mission-control/src/middleware.ts +82 -0
  31. package/package.json +1 -1
  32. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  33. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  34. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  35. package/services/launchd/ai.openclaw.mission-control.plist +1 -1
  36. package/uninstall.sh +37 -9
package/lib/mesh-plans.js CHANGED
@@ -210,6 +210,14 @@ function assignWaves(subtasks) {
210
210
  wave++;
211
211
  currentWave = nextWave;
212
212
  }
213
+
214
+ // Detect cycles: any node with remaining in-degree > 0 is in a cycle
215
+ for (const [taskId, degree] of inDegree.entries()) {
216
+ if (degree > 0) {
217
+ const subtask = idMap.get(taskId);
218
+ if (subtask) subtask.wave = -1; // blocked by cycle
219
+ }
220
+ }
213
221
  }
214
222
 
215
223
  // ── Delegation Decision Tree ───────────────────────
@@ -351,6 +359,28 @@ class PlanStore {
351
359
  return JSON.parse(sc.decode(entry.value));
352
360
  }
353
361
 
362
+ /**
363
+ * Compare-and-swap helper: read → mutate → write with optimistic concurrency.
364
+ * Re-reads and retries on conflict (up to maxRetries).
365
+ * mutateFn receives the parsed data and must return the updated object, or falsy to skip.
366
+ */
367
+ async _updateWithCAS(key, mutateFn, maxRetries = 3) {
368
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
369
+ const entry = await this.kv.get(key);
370
+ if (!entry) return null;
371
+ const data = JSON.parse(sc.decode(entry.value));
372
+ const updated = mutateFn(data);
373
+ if (!updated) return null;
374
+ try {
375
+ await this.kv.put(key, sc.encode(JSON.stringify(updated)), { previousSeq: entry.revision });
376
+ return updated;
377
+ } catch (err) {
378
+ if (attempt === maxRetries - 1) throw err;
379
+ // conflict — retry
380
+ }
381
+ }
382
+ }
383
+
354
384
  async delete(planId) {
355
385
  await this.kv.delete(planId);
356
386
  }
@@ -389,69 +419,60 @@ class PlanStore {
389
419
  // ── Lifecycle ───────────────────────────────────
390
420
 
391
421
  async submitForReview(planId) {
392
- const plan = await this.get(planId);
393
- if (!plan) return null;
394
- plan.status = PLAN_STATUS.REVIEW;
395
- await this.put(plan);
396
- return plan;
422
+ return this._updateWithCAS(planId, (plan) => {
423
+ plan.status = PLAN_STATUS.REVIEW;
424
+ return plan;
425
+ });
397
426
  }
398
427
 
399
428
  async approve(planId, approvedBy = 'gui') {
400
- const plan = await this.get(planId);
401
- if (!plan) return null;
402
- plan.status = PLAN_STATUS.APPROVED;
403
- plan.approved_by = approvedBy;
404
- plan.approved_at = new Date().toISOString();
405
- await this.put(plan);
406
- return plan;
429
+ return this._updateWithCAS(planId, (plan) => {
430
+ plan.status = PLAN_STATUS.APPROVED;
431
+ plan.approved_by = approvedBy;
432
+ plan.approved_at = new Date().toISOString();
433
+ return plan;
434
+ });
407
435
  }
408
436
 
409
437
  async startExecuting(planId) {
410
- const plan = await this.get(planId);
411
- if (!plan) return null;
412
- plan.status = PLAN_STATUS.EXECUTING;
413
- plan.started_at = new Date().toISOString();
414
- await this.put(plan);
415
- return plan;
438
+ return this._updateWithCAS(planId, (plan) => {
439
+ plan.status = PLAN_STATUS.EXECUTING;
440
+ plan.started_at = new Date().toISOString();
441
+ return plan;
442
+ });
416
443
  }
417
444
 
418
445
  async markCompleted(planId) {
419
- const plan = await this.get(planId);
420
- if (!plan) return null;
421
- plan.status = PLAN_STATUS.COMPLETED;
422
- plan.completed_at = new Date().toISOString();
423
- await this.put(plan);
424
- return plan;
446
+ return this._updateWithCAS(planId, (plan) => {
447
+ plan.status = PLAN_STATUS.COMPLETED;
448
+ plan.completed_at = new Date().toISOString();
449
+ return plan;
450
+ });
425
451
  }
426
452
 
427
453
  async markAborted(planId, reason) {
428
- const plan = await this.get(planId);
429
- if (!plan) return null;
430
- plan.status = PLAN_STATUS.ABORTED;
431
- plan.completed_at = new Date().toISOString();
432
- // Mark all pending subtasks as blocked
433
- for (const st of plan.subtasks) {
434
- if (st.status === SUBTASK_STATUS.PENDING || st.status === SUBTASK_STATUS.QUEUED) {
435
- st.status = SUBTASK_STATUS.BLOCKED;
436
- st.result = { success: false, summary: `Plan aborted: ${reason}` };
454
+ return this._updateWithCAS(planId, (plan) => {
455
+ plan.status = PLAN_STATUS.ABORTED;
456
+ plan.completed_at = new Date().toISOString();
457
+ for (const st of plan.subtasks) {
458
+ if (st.status === SUBTASK_STATUS.PENDING || st.status === SUBTASK_STATUS.QUEUED) {
459
+ st.status = SUBTASK_STATUS.BLOCKED;
460
+ st.result = { success: false, summary: `Plan aborted: ${reason}` };
461
+ }
437
462
  }
438
- }
439
- await this.put(plan);
440
- return plan;
463
+ return plan;
464
+ });
441
465
  }
442
466
 
443
467
  // ── Subtask Management ──────────────────────────
444
468
 
445
469
  async updateSubtask(planId, subtaskId, updates) {
446
- const plan = await this.get(planId);
447
- if (!plan) return null;
448
-
449
- const st = plan.subtasks.find(s => s.subtask_id === subtaskId);
450
- if (!st) return null;
451
-
452
- Object.assign(st, updates);
453
- await this.put(plan);
454
- return plan;
470
+ return this._updateWithCAS(planId, (plan) => {
471
+ const st = plan.subtasks.find(s => s.subtask_id === subtaskId);
472
+ if (!st) return null;
473
+ Object.assign(st, updates);
474
+ return plan;
475
+ });
455
476
  }
456
477
 
457
478
  /**
package/lib/mesh-tasks.js CHANGED
@@ -142,6 +142,28 @@ class TaskStore {
142
142
  return JSON.parse(sc.decode(entry.value));
143
143
  }
144
144
 
145
+ /**
146
+ * Compare-and-swap helper: read → mutate → write with optimistic concurrency.
147
+ * Re-reads and retries on conflict (up to maxRetries).
148
+ * mutateFn receives the parsed data and must return the updated object, or falsy to skip.
149
+ */
150
+ async _updateWithCAS(key, mutateFn, maxRetries = 3) {
151
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
152
+ const entry = await this.kv.get(key);
153
+ if (!entry) return null;
154
+ const data = JSON.parse(sc.decode(entry.value));
155
+ const updated = mutateFn(data);
156
+ if (!updated) return null;
157
+ try {
158
+ await this.kv.put(key, sc.encode(JSON.stringify(updated)), { previousSeq: entry.revision });
159
+ return updated;
160
+ } catch (err) {
161
+ if (attempt === maxRetries - 1) throw err;
162
+ // conflict — retry
163
+ }
164
+ }
165
+ }
166
+
145
167
  async delete(taskId) {
146
168
  await this.kv.delete(taskId);
147
169
  }
@@ -213,39 +235,40 @@ class TaskStore {
213
235
  if (claimable.length === 0) return null;
214
236
 
215
237
  const task = claimable[0];
216
- task.status = TASK_STATUS.CLAIMED;
217
- task.owner = nodeId;
218
- task.claimed_at = new Date().toISOString();
219
- const budgetMs = (task.budget_minutes || 30) * 60 * 1000;
220
- task.budget_deadline = new Date(Date.now() + budgetMs).toISOString();
221
-
222
- await this.put(task);
223
- return task;
238
+ const result = await this._updateWithCAS(task.task_id, (t) => {
239
+ // Re-check status under CAS — another node may have claimed it
240
+ if (t.status !== TASK_STATUS.QUEUED) return null;
241
+ t.status = TASK_STATUS.CLAIMED;
242
+ t.owner = nodeId;
243
+ t.claimed_at = new Date().toISOString();
244
+ const budgetMs = (t.budget_minutes || 30) * 60 * 1000;
245
+ t.budget_deadline = new Date(Date.now() + budgetMs).toISOString();
246
+ return t;
247
+ });
248
+ return result;
224
249
  }
225
250
 
226
251
  /**
227
252
  * Mark a task as running (agent started work).
228
253
  */
229
254
  async markRunning(taskId) {
230
- const task = await this.get(taskId);
231
- if (!task) return null;
232
- task.status = TASK_STATUS.RUNNING;
233
- task.started_at = new Date().toISOString();
234
- await this.put(task);
235
- return task;
255
+ return this._updateWithCAS(taskId, (task) => {
256
+ task.status = TASK_STATUS.RUNNING;
257
+ task.started_at = new Date().toISOString();
258
+ return task;
259
+ });
236
260
  }
237
261
 
238
262
  /**
239
263
  * Mark a task as completed with result.
240
264
  */
241
265
  async markCompleted(taskId, result) {
242
- const task = await this.get(taskId);
243
- if (!task) return null;
244
- task.status = TASK_STATUS.COMPLETED;
245
- task.completed_at = new Date().toISOString();
246
- task.result = result;
247
- await this.put(task);
248
- return task;
266
+ return this._updateWithCAS(taskId, (task) => {
267
+ task.status = TASK_STATUS.COMPLETED;
268
+ task.completed_at = new Date().toISOString();
269
+ task.result = result;
270
+ return task;
271
+ });
249
272
  }
250
273
 
251
274
  /**
@@ -253,70 +276,65 @@ class TaskStore {
253
276
  * Stores the result but doesn't transition to completed.
254
277
  */
255
278
  async markPendingReview(taskId, result) {
256
- const task = await this.get(taskId);
257
- if (!task) return null;
258
- task.status = TASK_STATUS.PENDING_REVIEW;
259
- task.result = result;
260
- task.review_requested_at = new Date().toISOString();
261
- await this.put(task);
262
- return task;
279
+ return this._updateWithCAS(taskId, (task) => {
280
+ task.status = TASK_STATUS.PENDING_REVIEW;
281
+ task.result = result;
282
+ task.review_requested_at = new Date().toISOString();
283
+ return task;
284
+ });
263
285
  }
264
286
 
265
287
  /**
266
288
  * Approve a pending_review task → completed.
267
289
  */
268
290
  async markApproved(taskId) {
269
- const task = await this.get(taskId);
270
- if (!task) return null;
271
- if (task.status !== TASK_STATUS.PENDING_REVIEW) return null;
272
- task.status = TASK_STATUS.COMPLETED;
273
- task.completed_at = new Date().toISOString();
274
- task.reviewed_by = 'human';
275
- await this.put(task);
276
- return task;
291
+ return this._updateWithCAS(taskId, (task) => {
292
+ if (task.status !== TASK_STATUS.PENDING_REVIEW) return null;
293
+ task.status = TASK_STATUS.COMPLETED;
294
+ task.completed_at = new Date().toISOString();
295
+ task.reviewed_by = 'human';
296
+ return task;
297
+ });
277
298
  }
278
299
 
279
300
  /**
280
301
  * Reject a pending_review task → re-queue with reason.
281
302
  */
282
303
  async markRejected(taskId, reason) {
283
- const task = await this.get(taskId);
284
- if (!task) return null;
285
- if (task.status !== TASK_STATUS.PENDING_REVIEW) return null;
286
- task.status = TASK_STATUS.QUEUED;
287
- task.rejection_reason = reason;
288
- task.result = null; // clear previous result
289
- task.review_requested_at = null;
290
- await this.put(task);
291
- return task;
304
+ return this._updateWithCAS(taskId, (task) => {
305
+ if (task.status !== TASK_STATUS.PENDING_REVIEW) return null;
306
+ task.status = TASK_STATUS.QUEUED;
307
+ task.rejection_reason = reason;
308
+ task.result = null;
309
+ task.review_requested_at = null;
310
+ return task;
311
+ });
292
312
  }
293
313
 
294
314
  /**
295
315
  * Mark a task as failed with reason.
296
316
  */
297
317
  async markFailed(taskId, reason, attempts = []) {
298
- const task = await this.get(taskId);
299
- if (!task) return null;
300
- task.status = TASK_STATUS.FAILED;
301
- task.completed_at = new Date().toISOString();
302
- task.result = { success: false, summary: reason };
303
- task.attempts = attempts;
304
- await this.put(task);
305
- return task;
318
+ return this._updateWithCAS(taskId, (task) => {
319
+ task.status = TASK_STATUS.FAILED;
320
+ task.completed_at = new Date().toISOString();
321
+ task.result = { success: false, summary: reason };
322
+ task.attempts = attempts;
323
+ return task;
324
+ });
306
325
  }
307
326
 
308
327
  /**
309
328
  * Log an attempt on a task (agent tried something, may or may not have worked).
310
329
  */
311
330
  async logAttempt(taskId, attempt) {
312
- const task = await this.get(taskId);
313
- if (!task) return null;
314
- task.attempts.push({
315
- ...attempt,
316
- timestamp: new Date().toISOString(),
331
+ return this._updateWithCAS(taskId, (task) => {
332
+ task.attempts.push({
333
+ ...attempt,
334
+ timestamp: new Date().toISOString(),
335
+ });
336
+ return task;
317
337
  });
318
- await this.put(task);
319
- return task;
320
338
  }
321
339
 
322
340
  /**
@@ -324,25 +342,23 @@ class TaskStore {
324
342
  * Distinct from failed: failed = "didn't work", released = "we tried everything."
325
343
  */
326
344
  async markReleased(taskId, reason, attempts = []) {
327
- const task = await this.get(taskId);
328
- if (!task) return null;
329
- task.status = TASK_STATUS.RELEASED;
330
- task.completed_at = new Date().toISOString();
331
- task.result = { success: false, summary: reason, released: true };
332
- if (attempts.length > 0) task.attempts = attempts;
333
- await this.put(task);
334
- return task;
345
+ return this._updateWithCAS(taskId, (task) => {
346
+ task.status = TASK_STATUS.RELEASED;
347
+ task.completed_at = new Date().toISOString();
348
+ task.result = { success: false, summary: reason, released: true };
349
+ if (attempts.length > 0) task.attempts = attempts;
350
+ return task;
351
+ });
335
352
  }
336
353
 
337
354
  /**
338
355
  * Update last_activity timestamp (agent heartbeat).
339
356
  */
340
357
  async touchActivity(taskId) {
341
- const task = await this.get(taskId);
342
- if (!task) return null;
343
- task.last_activity = new Date().toISOString();
344
- await this.put(task);
345
- return task;
358
+ return this._updateWithCAS(taskId, (task) => {
359
+ task.last_activity = new Date().toISOString();
360
+ return task;
361
+ });
346
362
  }
347
363
 
348
364
  /**
@@ -37,7 +37,7 @@ function resolveNatsUrl() {
37
37
  if (fs.existsSync(envFile)) {
38
38
  const content = fs.readFileSync(envFile, 'utf8');
39
39
  const match = content.match(/^\s*OPENCLAW_NATS\s*=\s*(.+)/m);
40
- if (match && match[1].trim()) return match[1].trim();
40
+ if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
41
41
  }
42
42
  } catch {
43
43
  // File unreadable — fall through silently
@@ -49,7 +49,7 @@ function resolveNatsUrl() {
49
49
  if (fs.existsSync(meshConfig)) {
50
50
  const content = fs.readFileSync(meshConfig, 'utf8');
51
51
  const match = content.match(/^\s*OPENCLAW_NATS\s*=\s*(.+)/m);
52
- if (match && match[1].trim()) return match[1].trim();
52
+ if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
53
53
  }
54
54
  } catch {
55
55
  // File unreadable — fall through silently
@@ -73,7 +73,7 @@ function resolveNatsToken() {
73
73
  if (fs.existsSync(envFile)) {
74
74
  const content = fs.readFileSync(envFile, 'utf8');
75
75
  const match = content.match(/^\s*OPENCLAW_NATS_TOKEN\s*=\s*(.+)/m);
76
- if (match && match[1].trim()) return match[1].trim();
76
+ if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
77
77
  }
78
78
  } catch {}
79
79
 
@@ -83,7 +83,7 @@ function resolveNatsToken() {
83
83
  if (fs.existsSync(meshConfig)) {
84
84
  const content = fs.readFileSync(meshConfig, 'utf8');
85
85
  const match = content.match(/^\s*OPENCLAW_NATS_TOKEN\s*=\s*(.+)/m);
86
- if (match && match[1].trim()) return match[1].trim();
86
+ if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, '');
87
87
  }
88
88
  } catch {}
89
89
 
@@ -74,6 +74,8 @@ export async function shouldFlush(jsonlPath, opts = {}) {
74
74
  const { contextWindowTokens = 200000, flushPct = 0.75 } = opts;
75
75
  const threshold = Math.floor(contextWindowTokens * flushPct);
76
76
 
77
+ if (!fs.existsSync(jsonlPath)) return { shouldFlush: false, estimatedTokens: 0, pctUsed: 0, threshold };
78
+
77
79
  const stat = fs.statSync(jsonlPath);
78
80
  // Quick estimate from file size — ~4 chars/token, but JSONL has overhead (~2x)
79
81
  const quickEstimate = Math.ceil(stat.size / (CHARS_PER_TOKEN * 2));
@@ -398,10 +398,13 @@ export class SessionStore {
398
398
  * - Escapes quotes
399
399
  */
400
400
  #buildFtsQuery(query) {
401
- const cleaned = query.replace(/"/g, '""').trim();
402
- if (!cleaned) return null;
401
+ if (!query || !query.trim()) return null;
402
+ // Escape double quotes, then strip FTS5 operators to prevent query injection
403
+ const escaped = query.replace(/"/g, '""').trim();
404
+ const safe = escaped.replace(/[*(){}^]/g, '');
405
+ if (!safe) return null;
403
406
 
404
- const words = cleaned.split(/\s+/).filter(w => w.length > 0);
407
+ const words = safe.split(/\s+/).filter(w => w.length > 0);
405
408
  if (words.length === 0) return null;
406
409
 
407
410
  if (words.length === 1) {
@@ -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
  }
@@ -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
 
@@ -93,7 +99,12 @@ export async function POST(
93
99
  { params }: { params: Promise<{ id: string }> }
94
100
  ) {
95
101
  try {
96
- const { id: soulId } = await params;
102
+ let soulId: string;
103
+ try {
104
+ soulId = validatePathParam((await params).id);
105
+ } catch {
106
+ return NextResponse.json({ error: "Invalid soul ID" }, { status: 400 });
107
+ }
97
108
  const event: EvolutionEvent = await request.json();
98
109
 
99
110
  const db = getDb();
@@ -133,7 +144,12 @@ export async function PATCH(
133
144
  { params }: { params: Promise<{ id: string }> }
134
145
  ) {
135
146
  try {
136
- const { id: soulId } = await params;
147
+ let soulId: string;
148
+ try {
149
+ soulId = validatePathParam((await params).id);
150
+ } catch {
151
+ return NextResponse.json({ error: "Invalid soul ID" }, { status: 400 });
152
+ }
137
153
  const { searchParams } = new URL(request.url);
138
154
  const eventId = searchParams.get("eventId");
139
155
 
@@ -170,11 +186,12 @@ export async function PATCH(
170
186
  }
171
187
 
172
188
  // Apply change (e.g., update genes.json)
189
+ const safeTarget = validatePathParam(event.proposedChange.target);
173
190
  const targetPath = path.join(
174
191
  SOULS_DIR,
175
192
  soulId,
176
193
  "evolution",
177
- event.proposedChange.target
194
+ safeTarget
178
195
  );
179
196
 
180
197
  if (event.proposedChange.action === "add") {
@@ -189,7 +206,6 @@ export async function PATCH(
189
206
  // Sanitize all user-derived inputs: eventId, soulId, reviewedBy, event.summary
190
207
  // could all contain shell metacharacters if crafted maliciously.
191
208
  const safeBranch = `evolution/${eventId.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
192
- const safeTarget = event.proposedChange.target.replace(/[^a-zA-Z0-9._/-]/g, "_");
193
209
  const commitMessage = [
194
210
  `evolution(${eventId}): ${event.summary}`,
195
211
  "",
@@ -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);
@@ -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();
@@ -25,6 +25,17 @@ export async function GET(request: NextRequest) {
25
25
  return NextResponse.json({ error: "File not found" }, { status: 404 });
26
26
  }
27
27
 
28
+ // Defeat symlink traversal: resolve the real path and re-check prefix
29
+ const realPath = fs.realpathSync(absPath);
30
+ const realRoot = fs.realpathSync(WORKSPACE_ROOT);
31
+ if (!realPath.startsWith(realRoot + path.sep) && realPath !== realRoot) {
32
+ return NextResponse.json({ error: "Path traversal denied" }, { status: 403 });
33
+ }
34
+
35
+ if (!fs.existsSync(realPath)) {
36
+ return NextResponse.json({ error: "File not found" }, { status: 404 });
37
+ }
38
+
28
39
  const stat = fs.statSync(absPath);
29
40
  if (stat.isDirectory()) {
30
41
  return NextResponse.json({ error: "Path is a directory" }, { status: 400 });
@@ -65,6 +65,15 @@ export function getProviderModels(provider?: string): Record<CapabilityTier, str
65
65
  /** All registered provider names */
66
66
  export const AVAILABLE_PROVIDERS = Object.keys(MODEL_MAP);
67
67
 
68
+ /** Validates that a URL path parameter is safe for use in file paths. */
69
+ export function validatePathParam(param: string): string {
70
+ const cleaned = param.trim();
71
+ if (!cleaned || !/^[\w][\w.-]{0,127}$/.test(cleaned)) {
72
+ throw new Error(`Invalid path parameter: "${param.slice(0, 40)}"`);
73
+ }
74
+ return cleaned;
75
+ }
76
+
68
77
  // ── Node Identity (Distributed MC) ──
69
78
  import { hostname } from "os";
70
79