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.
- package/bin/lane-watchdog.js +23 -2
- package/bin/mesh-agent.js +38 -16
- package/bin/mesh-bridge.js +3 -2
- package/bin/mesh-health-publisher.js +41 -1
- package/bin/mesh-task-daemon.js +5 -0
- package/bin/mesh.js +8 -19
- package/install.sh +3 -2
- package/lib/agent-activity.js +2 -2
- package/lib/exec-safety.js +105 -0
- package/lib/kanban-io.js +15 -31
- package/lib/llm-providers.js +16 -0
- package/lib/mcp-knowledge/core.mjs +7 -5
- package/lib/mcp-knowledge/server.mjs +8 -1
- package/lib/mesh-collab.js +268 -250
- package/lib/mesh-plans.js +66 -45
- package/lib/mesh-tasks.js +89 -73
- package/lib/nats-resolve.js +4 -4
- package/lib/pre-compression-flush.mjs +2 -0
- package/lib/session-store.mjs +6 -3
- package/mission-control/src/app/api/memory/search/route.ts +6 -3
- package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
- package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
- package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +7 -1
- package/mission-control/src/app/api/workspace/read/route.ts +11 -0
- package/mission-control/src/lib/config.ts +9 -0
- package/mission-control/src/lib/db/index.ts +16 -1
- package/mission-control/src/lib/memory/extract.ts +2 -1
- package/mission-control/src/lib/memory/retrieval.ts +3 -2
- package/mission-control/src/middleware.ts +82 -0
- package/package.json +1 -1
- package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
- package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
- package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
- package/services/launchd/ai.openclaw.mission-control.plist +1 -1
- 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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
/**
|
package/lib/nats-resolve.js
CHANGED
|
@@ -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));
|
package/lib/session-store.mjs
CHANGED
|
@@ -398,10 +398,13 @@ export class SessionStore {
|
|
|
398
398
|
* - Escapes quotes
|
|
399
399
|
*/
|
|
400
400
|
#buildFtsQuery(query) {
|
|
401
|
-
|
|
402
|
-
|
|
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 =
|
|
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 =
|
|
36
|
+
const terms = safeQuery.split(/\s+/);
|
|
34
37
|
if (terms.length === 1) {
|
|
35
38
|
// Single term: use prefix matching
|
|
36
|
-
return `"${
|
|
39
|
+
return `"${safeQuery}"*`;
|
|
37
40
|
}
|
|
38
41
|
// Multi-term: quote as phrase + add individual terms with OR for broader recall
|
|
39
|
-
const phrase = `"${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|