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/bin/lane-watchdog.js
CHANGED
|
@@ -37,6 +37,11 @@ let lastInterventionAt = 0;
|
|
|
37
37
|
let logWatcher = null;
|
|
38
38
|
let errWatcher = null;
|
|
39
39
|
|
|
40
|
+
// Incident log dedup: suppress identical messages within 60s
|
|
41
|
+
let lastIncidentMsg = '';
|
|
42
|
+
let lastIncidentAt = 0;
|
|
43
|
+
let suppressedCount = 0;
|
|
44
|
+
|
|
40
45
|
// Track detected events
|
|
41
46
|
const events = {
|
|
42
47
|
agentTimeout: null, // timestamp of last "embedded run timeout"
|
|
@@ -45,6 +50,22 @@ const events = {
|
|
|
45
50
|
|
|
46
51
|
// --- Helpers ---
|
|
47
52
|
function log(msg) {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
// Dedup: suppress identical messages within 60s
|
|
55
|
+
if (msg === lastIncidentMsg && (now - lastIncidentAt) < 60_000) {
|
|
56
|
+
suppressedCount++;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// If we suppressed duplicates, emit a summary before the new message
|
|
60
|
+
if (suppressedCount > 0) {
|
|
61
|
+
const summaryLine = `${new Date().toISOString()} [lane-watchdog] (suppressed ${suppressedCount} duplicate message(s))`;
|
|
62
|
+
console.log(summaryLine);
|
|
63
|
+
try { fs.appendFileSync(INCIDENT_LOG, summaryLine + '\n'); } catch { /* best effort */ }
|
|
64
|
+
}
|
|
65
|
+
lastIncidentMsg = msg;
|
|
66
|
+
lastIncidentAt = now;
|
|
67
|
+
suppressedCount = 0;
|
|
68
|
+
|
|
48
69
|
const ts = new Date().toISOString();
|
|
49
70
|
const line = `${ts} [lane-watchdog] ${msg}`;
|
|
50
71
|
console.log(line);
|
|
@@ -220,8 +241,8 @@ function main() {
|
|
|
220
241
|
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
221
242
|
process.on(sig, () => {
|
|
222
243
|
log(`Received ${sig}, shutting down`);
|
|
223
|
-
if (logWatcher)
|
|
224
|
-
if (errWatcher)
|
|
244
|
+
if (logWatcher) logWatcher.close();
|
|
245
|
+
if (errWatcher) errWatcher.close();
|
|
225
246
|
process.exit(0);
|
|
226
247
|
});
|
|
227
248
|
}
|
package/bin/mesh-agent.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
const { connect, StringCodec } = require('nats');
|
|
39
|
-
const { spawn, execSync } = require('child_process');
|
|
39
|
+
const { spawn, execSync, execFileSync } = require('child_process');
|
|
40
40
|
const os = require('os');
|
|
41
41
|
const path = require('path');
|
|
42
42
|
const fs = require('fs');
|
|
@@ -46,7 +46,7 @@ const { loadHarnessRules, runMeshHarness, runPostCommitValidation, formatHarness
|
|
|
46
46
|
const { findRole, formatRoleForPrompt } = require('../lib/role-loader');
|
|
47
47
|
|
|
48
48
|
const sc = StringCodec();
|
|
49
|
-
const { NATS_URL } = require('../lib/nats-resolve');
|
|
49
|
+
const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
|
|
50
50
|
const { resolveProvider, resolveModel } = require('../lib/llm-providers');
|
|
51
51
|
const NODE_ID = process.env.MESH_NODE_ID || os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
52
52
|
const POLL_INTERVAL = parseInt(process.env.MESH_POLL_INTERVAL || '15000'); // 15s between polls
|
|
@@ -198,8 +198,9 @@ function buildInitialPrompt(task) {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
if (task.metric) {
|
|
201
|
+
const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
|
|
201
202
|
parts.push(`## Verification`);
|
|
202
|
-
parts.push(`Run this command to check your work: \`${
|
|
203
|
+
parts.push(`Run this command to check your work: \`${safeMetric}\``);
|
|
203
204
|
parts.push(`Your changes are only accepted if this command exits with code 0.`);
|
|
204
205
|
parts.push('');
|
|
205
206
|
}
|
|
@@ -224,7 +225,8 @@ function buildInitialPrompt(task) {
|
|
|
224
225
|
parts.push('- Make minimal, focused changes. Do not add scope beyond what is asked.');
|
|
225
226
|
parts.push('- If you hit a blocker you cannot resolve, explain what is blocking you clearly.');
|
|
226
227
|
if (task.metric) {
|
|
227
|
-
|
|
228
|
+
const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
|
|
229
|
+
parts.push(`- After making changes, run \`${safeMetric}\` to verify.`);
|
|
228
230
|
parts.push('- If verification fails, analyze the failure and iterate on your approach.');
|
|
229
231
|
}
|
|
230
232
|
|
|
@@ -264,8 +266,9 @@ function buildRetryPrompt(task, previousAttempts, attemptNumber) {
|
|
|
264
266
|
}
|
|
265
267
|
|
|
266
268
|
if (task.metric) {
|
|
269
|
+
const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
|
|
267
270
|
parts.push(`## Verification`);
|
|
268
|
-
parts.push(`Run: \`${
|
|
271
|
+
parts.push(`Run: \`${safeMetric}\``);
|
|
269
272
|
parts.push(`Must exit code 0.`);
|
|
270
273
|
parts.push('');
|
|
271
274
|
}
|
|
@@ -289,7 +292,8 @@ function buildRetryPrompt(task, previousAttempts, attemptNumber) {
|
|
|
289
292
|
parts.push('- Read the relevant files before making changes.');
|
|
290
293
|
parts.push('- Make minimal, focused changes.');
|
|
291
294
|
if (task.metric) {
|
|
292
|
-
|
|
295
|
+
const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
|
|
296
|
+
parts.push(`- Run \`${safeMetric}\` to verify before finishing.`);
|
|
293
297
|
}
|
|
294
298
|
|
|
295
299
|
return parts.join('\n');
|
|
@@ -305,6 +309,9 @@ const WORKTREE_BASE = process.env.MESH_WORKTREE_BASE || path.join(process.env.HO
|
|
|
305
309
|
* On failure, returns null (falls back to shared workspace).
|
|
306
310
|
*/
|
|
307
311
|
function createWorktree(taskId) {
|
|
312
|
+
if (!/^[\w][\w.-]{0,127}$/.test(taskId)) {
|
|
313
|
+
throw new Error(`Invalid taskId: contains unsafe characters`);
|
|
314
|
+
}
|
|
308
315
|
const worktreePath = path.join(WORKTREE_BASE, taskId);
|
|
309
316
|
const branch = `mesh/${taskId}`;
|
|
310
317
|
|
|
@@ -315,19 +322,19 @@ function createWorktree(taskId) {
|
|
|
315
322
|
if (fs.existsSync(worktreePath)) {
|
|
316
323
|
log(`Cleaning stale worktree: ${worktreePath}`);
|
|
317
324
|
try {
|
|
318
|
-
|
|
325
|
+
execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: WORKSPACE, timeout: 10000 });
|
|
319
326
|
} catch {
|
|
320
327
|
// If git worktree remove fails, manually clean up
|
|
321
328
|
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
322
329
|
}
|
|
323
330
|
// Also clean up the branch if it exists
|
|
324
331
|
try {
|
|
325
|
-
|
|
332
|
+
execFileSync('git', ['branch', '-D', branch], { cwd: WORKSPACE, timeout: 5000, stdio: 'ignore' });
|
|
326
333
|
} catch { /* branch may not exist */ }
|
|
327
334
|
}
|
|
328
335
|
|
|
329
336
|
// Create new worktree branched off HEAD
|
|
330
|
-
|
|
337
|
+
execFileSync('git', ['worktree', 'add', '-b', branch, worktreePath, 'HEAD'], {
|
|
331
338
|
cwd: WORKSPACE,
|
|
332
339
|
timeout: 30000,
|
|
333
340
|
stdio: 'pipe',
|
|
@@ -375,7 +382,7 @@ function commitAndMergeWorktree(worktreePath, taskId, summary) {
|
|
|
375
382
|
log(`WARNING: commit message doesn't follow conventional format: "${commitMsg}"`);
|
|
376
383
|
}
|
|
377
384
|
|
|
378
|
-
|
|
385
|
+
execFileSync('git', ['commit', '-m', commitMsg], {
|
|
379
386
|
cwd: worktreePath, timeout: 10000, stdio: 'pipe',
|
|
380
387
|
});
|
|
381
388
|
|
|
@@ -391,7 +398,7 @@ function commitAndMergeWorktree(worktreePath, taskId, summary) {
|
|
|
391
398
|
const mergeMsg = `Merge ${branch}: ${taskId}`;
|
|
392
399
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
393
400
|
try {
|
|
394
|
-
|
|
401
|
+
execFileSync('git', ['merge', '--no-ff', branch, '-m', mergeMsg], {
|
|
395
402
|
cwd: WORKSPACE, timeout: 30000, stdio: 'pipe',
|
|
396
403
|
});
|
|
397
404
|
log(`Merged ${branch} into main${attempt > 0 ? ' (retry succeeded)' : ''}`);
|
|
@@ -429,13 +436,13 @@ function cleanupWorktree(worktreePath, keep = false) {
|
|
|
429
436
|
const branch = `mesh/${taskId}`;
|
|
430
437
|
|
|
431
438
|
try {
|
|
432
|
-
|
|
439
|
+
execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
|
|
433
440
|
cwd: WORKSPACE,
|
|
434
441
|
timeout: 10000,
|
|
435
442
|
stdio: 'pipe',
|
|
436
443
|
});
|
|
437
444
|
if (!keep) {
|
|
438
|
-
|
|
445
|
+
execFileSync('git', ['branch', '-D', branch], {
|
|
439
446
|
cwd: WORKSPACE,
|
|
440
447
|
timeout: 5000,
|
|
441
448
|
stdio: 'ignore',
|
|
@@ -507,9 +514,10 @@ function runLLM(prompt, task, worktreePath) {
|
|
|
507
514
|
|
|
508
515
|
let stdout = '';
|
|
509
516
|
let stderr = '';
|
|
517
|
+
const MAX_OUTPUT = 1024 * 1024; // 1MB cap
|
|
510
518
|
|
|
511
|
-
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
512
|
-
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
519
|
+
child.stdout.on('data', (d) => { if (stdout.length < MAX_OUTPUT) stdout += d.toString().slice(0, MAX_OUTPUT - stdout.length); });
|
|
520
|
+
child.stderr.on('data', (d) => { if (stderr.length < MAX_OUTPUT) stderr += d.toString().slice(0, MAX_OUTPUT - stderr.length); });
|
|
513
521
|
|
|
514
522
|
child.on('close', (code) => {
|
|
515
523
|
clearInterval(heartbeatTimer);
|
|
@@ -525,10 +533,23 @@ function runLLM(prompt, task, worktreePath) {
|
|
|
525
533
|
|
|
526
534
|
// ── Metric Evaluation ─────────────────────────────────
|
|
527
535
|
|
|
536
|
+
const ALLOWED_METRIC_PREFIXES = [
|
|
537
|
+
'npm test', 'npm run', 'node ', 'pytest', 'cargo test',
|
|
538
|
+
'go test', 'make test', 'jest', 'vitest', 'mocha',
|
|
539
|
+
];
|
|
540
|
+
|
|
541
|
+
function isAllowedMetric(cmd) {
|
|
542
|
+
return ALLOWED_METRIC_PREFIXES.some(prefix => cmd.startsWith(prefix));
|
|
543
|
+
}
|
|
544
|
+
|
|
528
545
|
/**
|
|
529
546
|
* Run the task's metric command. Returns { passed, output }.
|
|
530
547
|
*/
|
|
531
548
|
function evaluateMetric(metric, cwd) {
|
|
549
|
+
if (!isAllowedMetric(metric)) {
|
|
550
|
+
log(`WARNING: Metric command blocked by security filter: ${metric}`);
|
|
551
|
+
return Promise.resolve({ passed: false, output: 'Metric command blocked by security filter' });
|
|
552
|
+
}
|
|
532
553
|
return new Promise((resolve) => {
|
|
533
554
|
const child = spawn('bash', ['-c', metric], {
|
|
534
555
|
cwd: cwd || WORKSPACE,
|
|
@@ -1394,8 +1415,9 @@ async function main() {
|
|
|
1394
1415
|
log(` Poll interval: ${POLL_INTERVAL / 1000}s`);
|
|
1395
1416
|
log(` Mode: ${ONCE ? 'single task' : 'continuous'} ${DRY_RUN ? '(dry run)' : ''}`);
|
|
1396
1417
|
|
|
1418
|
+
const natsOpts = natsConnectOpts();
|
|
1397
1419
|
nc = await connect({
|
|
1398
|
-
|
|
1420
|
+
...natsOpts,
|
|
1399
1421
|
timeout: 5000,
|
|
1400
1422
|
reconnect: true,
|
|
1401
1423
|
maxReconnectAttempts: 10,
|
package/bin/mesh-bridge.js
CHANGED
|
@@ -21,7 +21,7 @@ const path = require('path');
|
|
|
21
21
|
const { readTasks, updateTaskInPlace, isoTimestamp, ACTIVE_TASKS_PATH } = require('../lib/kanban-io');
|
|
22
22
|
|
|
23
23
|
const sc = StringCodec();
|
|
24
|
-
const { NATS_URL } = require('../lib/nats-resolve');
|
|
24
|
+
const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
|
|
25
25
|
const DISPATCH_INTERVAL = parseInt(process.env.BRIDGE_DISPATCH_INTERVAL || '10000'); // 10s
|
|
26
26
|
const LOG_DIR = path.join(process.env.HOME, '.openclaw', 'workspace', 'memory', 'mesh-logs');
|
|
27
27
|
const WORKSPACE = path.join(process.env.HOME, '.openclaw', 'workspace');
|
|
@@ -726,8 +726,9 @@ async function main() {
|
|
|
726
726
|
log(` Dispatch interval: ${DISPATCH_INTERVAL / 1000}s`);
|
|
727
727
|
log(` Mode: ${DRY_RUN ? 'dry run' : 'live'}`);
|
|
728
728
|
|
|
729
|
+
const natsOpts = natsConnectOpts();
|
|
729
730
|
nc = await connect({
|
|
730
|
-
|
|
731
|
+
...natsOpts,
|
|
731
732
|
timeout: 5000,
|
|
732
733
|
reconnect: true,
|
|
733
734
|
maxReconnectAttempts: 10,
|
|
@@ -36,6 +36,12 @@ const IS_MAC = os.platform() === "darwin";
|
|
|
36
36
|
|
|
37
37
|
const { ROLE_COMPONENTS } = require('../lib/mesh-roles');
|
|
38
38
|
|
|
39
|
+
// ── Circuit Breaker State ───────────────────────────────────────────────
|
|
40
|
+
let consecutiveFailures = 0;
|
|
41
|
+
let skipTicksRemaining = 0;
|
|
42
|
+
let lastErrorMsg = '';
|
|
43
|
+
let lastErrorRepeatCount = 0;
|
|
44
|
+
|
|
39
45
|
// ── Health Gathering ─────────────────────────────────────────────────────
|
|
40
46
|
// All the expensive execSync calls happen here, on our own schedule.
|
|
41
47
|
// No request timeout to race against.
|
|
@@ -226,11 +232,45 @@ async function main() {
|
|
|
226
232
|
|
|
227
233
|
// Publish immediately, then every interval
|
|
228
234
|
async function publish() {
|
|
235
|
+
// Circuit breaker: skip ticks during backoff
|
|
236
|
+
if (skipTicksRemaining > 0) {
|
|
237
|
+
skipTicksRemaining--;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
229
241
|
try {
|
|
230
242
|
const health = gatherHealth();
|
|
231
243
|
await kv.put(NODE_ID, sc.encode(JSON.stringify(health)));
|
|
244
|
+
// Reset on success
|
|
245
|
+
if (consecutiveFailures > 0) {
|
|
246
|
+
console.log(`[health-publisher] recovered after ${consecutiveFailures} consecutive failures`);
|
|
247
|
+
}
|
|
248
|
+
consecutiveFailures = 0;
|
|
249
|
+
lastErrorMsg = '';
|
|
250
|
+
lastErrorRepeatCount = 0;
|
|
232
251
|
} catch (err) {
|
|
233
|
-
|
|
252
|
+
consecutiveFailures++;
|
|
253
|
+
const msg = err.message;
|
|
254
|
+
|
|
255
|
+
// Log dedup: after 3 identical consecutive errors, log every 10th
|
|
256
|
+
if (msg === lastErrorMsg) {
|
|
257
|
+
lastErrorRepeatCount++;
|
|
258
|
+
if (lastErrorRepeatCount === 3) {
|
|
259
|
+
console.error(`[health-publisher] suppressing repeated errors (${lastErrorRepeatCount} occurrences): ${msg}`);
|
|
260
|
+
} else if (lastErrorRepeatCount > 3 && lastErrorRepeatCount % 10 === 0) {
|
|
261
|
+
console.error(`[health-publisher] suppressing repeated errors (${lastErrorRepeatCount} occurrences): ${msg}`);
|
|
262
|
+
}
|
|
263
|
+
// Silently skip logs between dedup thresholds
|
|
264
|
+
} else {
|
|
265
|
+
lastErrorMsg = msg;
|
|
266
|
+
lastErrorRepeatCount = 1;
|
|
267
|
+
console.error("[health-publisher] publish failed:", msg);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Exponential backoff: skip 2^min(N,6) ticks (max ~64 ticks / ~16 min at 15s)
|
|
271
|
+
const backoffTicks = Math.pow(2, Math.min(consecutiveFailures, 6));
|
|
272
|
+
skipTicksRemaining = backoffTicks;
|
|
273
|
+
console.error(`[health-publisher] backoff: skipping next ${backoffTicks} ticks (failures=${consecutiveFailures})`);
|
|
234
274
|
}
|
|
235
275
|
}
|
|
236
276
|
|
package/bin/mesh-task-daemon.js
CHANGED
|
@@ -2106,6 +2106,11 @@ async function main() {
|
|
|
2106
2106
|
clearInterval(budgetTimer);
|
|
2107
2107
|
clearInterval(stallTimer);
|
|
2108
2108
|
clearInterval(recruitTimer);
|
|
2109
|
+
if (circlingStepSweepTimer) clearInterval(circlingStepSweepTimer);
|
|
2110
|
+
if (circlingStepTimers) {
|
|
2111
|
+
for (const timer of circlingStepTimers.values()) clearTimeout(timer);
|
|
2112
|
+
circlingStepTimers.clear();
|
|
2113
|
+
}
|
|
2109
2114
|
for (const sub of subs) sub.unsubscribe();
|
|
2110
2115
|
await nc.drain();
|
|
2111
2116
|
process.exit(0);
|
package/bin/mesh.js
CHANGED
|
@@ -88,27 +88,16 @@ function remoteNode() {
|
|
|
88
88
|
|
|
89
89
|
// ─── Exec safety ─────────────────────────────────────
|
|
90
90
|
|
|
91
|
-
const
|
|
92
|
-
/\brm\s+(-[a-zA-Z]*)?r[a-zA-Z]*f/, // rm -rf, rm -fr, rm --recursive --force
|
|
93
|
-
/\brm\s+(-[a-zA-Z]*)?f[a-zA-Z]*r/, // rm -fr variants
|
|
94
|
-
/\bmkfs\b/, // format filesystem
|
|
95
|
-
/\bdd\s+.*of=/, // raw disk write
|
|
96
|
-
/\b>\s*\/dev\/[sh]d/, // write to raw device
|
|
97
|
-
/\bcurl\b.*\|\s*(ba)?sh/, // curl pipe to shell
|
|
98
|
-
/\bwget\b.*\|\s*(ba)?sh/, // wget pipe to shell
|
|
99
|
-
/\bchmod\s+(-[a-zA-Z]*\s+)?777\s+\//, // chmod 777 on root paths
|
|
100
|
-
/\b:(){ :\|:& };:/, // fork bomb
|
|
101
|
-
];
|
|
91
|
+
const { checkDestructivePatterns } = require('../lib/exec-safety');
|
|
102
92
|
|
|
103
93
|
function checkExecSafety(command) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
94
|
+
const result = checkDestructivePatterns(command);
|
|
95
|
+
if (result.blocked) {
|
|
96
|
+
console.error(`BLOCKED: Command matches destructive pattern.`);
|
|
97
|
+
console.error(` Command: ${command}`);
|
|
98
|
+
console.error(` Pattern: ${result.pattern}`);
|
|
99
|
+
console.error(`\nIf this is intentional, SSH into the node and run it directly.`);
|
|
100
|
+
process.exit(1);
|
|
112
101
|
}
|
|
113
102
|
}
|
|
114
103
|
|
package/install.sh
CHANGED
|
@@ -770,8 +770,9 @@ else
|
|
|
770
770
|
fi
|
|
771
771
|
|
|
772
772
|
if [ "$OS" = "macos" ]; then
|
|
773
|
-
|
|
774
|
-
|
|
773
|
+
LAUNCHD_SVC_NAME="${SVC_NAME#openclaw-}"
|
|
774
|
+
TEMPLATE="$LAUNCHD_TEMPLATES/ai.openclaw.${LAUNCHD_SVC_NAME}.plist"
|
|
775
|
+
DEST="$LAUNCHD_DEST/ai.openclaw.${LAUNCHD_SVC_NAME}.plist"
|
|
775
776
|
|
|
776
777
|
if [ ! -f "$TEMPLATE" ]; then
|
|
777
778
|
warn " Template not found: $TEMPLATE"
|
package/lib/agent-activity.js
CHANGED
|
@@ -147,7 +147,7 @@ async function readLastEntry(filePath) {
|
|
|
147
147
|
} else {
|
|
148
148
|
const fh = await open(filePath, 'r');
|
|
149
149
|
try {
|
|
150
|
-
const buffer = Buffer.
|
|
150
|
+
const buffer = Buffer.alloc(chunkSize);
|
|
151
151
|
await fh.read(buffer, 0, chunkSize, offset);
|
|
152
152
|
content = buffer.toString('utf-8');
|
|
153
153
|
} finally {
|
|
@@ -195,7 +195,7 @@ async function parseJsonlTail(filePath, maxBytes = 131072) {
|
|
|
195
195
|
const fh = await open(filePath, 'r');
|
|
196
196
|
try {
|
|
197
197
|
const length = size - offset;
|
|
198
|
-
const buffer = Buffer.
|
|
198
|
+
const buffer = Buffer.alloc(length);
|
|
199
199
|
await fh.read(buffer, 0, length, offset);
|
|
200
200
|
content = buffer.toString('utf-8');
|
|
201
201
|
} finally {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exec-safety.js — Shared command safety filtering for mesh exec.
|
|
3
|
+
*
|
|
4
|
+
* Used by both CLI-side (mesh.js) and server-side (NATS exec handler)
|
|
5
|
+
* to block destructive or unauthorized commands before execution.
|
|
6
|
+
*
|
|
7
|
+
* Two layers:
|
|
8
|
+
* 1. DESTRUCTIVE_PATTERNS — blocklist of known-dangerous patterns
|
|
9
|
+
* 2. ALLOWED_PREFIXES — allowlist for server-side execution (opt-in)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
15
|
+
/\brm\s+(-[a-zA-Z]*)?r[a-zA-Z]*f/, // rm -rf, rm -fr, rm --recursive --force
|
|
16
|
+
/\brm\s+(-[a-zA-Z]*)?f[a-zA-Z]*r/, // rm -fr variants
|
|
17
|
+
/\bmkfs\b/, // format filesystem
|
|
18
|
+
/\bdd\s+.*of=/, // raw disk write
|
|
19
|
+
/\b>\s*\/dev\/[sh]d/, // write to raw device
|
|
20
|
+
/\bcurl\b.*\|\s*(ba)?sh/, // curl pipe to shell
|
|
21
|
+
/\bwget\b.*\|\s*(ba)?sh/, // wget pipe to shell
|
|
22
|
+
/\bchmod\s+(-[a-zA-Z]*\s+)?777\s+\//, // chmod 777 on root paths
|
|
23
|
+
/\b:(){ :\|:& };:/, // fork bomb
|
|
24
|
+
/\bsudo\b/, // sudo escalation
|
|
25
|
+
/\bsu\s+-?\s/, // su user switch
|
|
26
|
+
/\bpasswd\b/, // password change
|
|
27
|
+
/\buseradd\b|\buserdel\b/, // user management
|
|
28
|
+
/\biptables\b|\bnft\b/, // firewall modification
|
|
29
|
+
/\bsystemctl\s+(stop|disable|mask)/, // service disruption
|
|
30
|
+
/\blaunchctl\s+(unload|remove)/, // macOS service disruption
|
|
31
|
+
/\bkill\s+-9\s+1\b/, // kill init/launchd
|
|
32
|
+
/>\s*\/etc\//, // overwrite system config
|
|
33
|
+
/\beval\b.*\$\(/, // eval with command substitution
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Allowed command prefixes for server-side NATS exec.
|
|
38
|
+
* Only commands starting with one of these are permitted.
|
|
39
|
+
* CLI-side uses blocklist only; server-side uses both blocklist + allowlist.
|
|
40
|
+
*/
|
|
41
|
+
const ALLOWED_EXEC_PREFIXES = [
|
|
42
|
+
'git ', 'npm ', 'node ', 'npx ', 'python ', 'python3 ',
|
|
43
|
+
'cat ', 'ls ', 'head ', 'tail ', 'grep ', 'find ', 'wc ',
|
|
44
|
+
'echo ', 'date ', 'uptime ', 'df ', 'free ', 'ps ',
|
|
45
|
+
'bash openclaw/', 'bash ~/openclaw/', 'bash ./bin/',
|
|
46
|
+
'cd ', 'pwd', 'which ', 'env ', 'printenv ',
|
|
47
|
+
'cargo ', 'go ', 'make ', 'pytest ', 'jest ', 'vitest ',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if a command matches any destructive pattern.
|
|
52
|
+
* @param {string} command
|
|
53
|
+
* @returns {{ blocked: boolean, pattern?: RegExp }}
|
|
54
|
+
*/
|
|
55
|
+
function checkDestructivePatterns(command) {
|
|
56
|
+
for (const pattern of DESTRUCTIVE_PATTERNS) {
|
|
57
|
+
if (pattern.test(command)) {
|
|
58
|
+
return { blocked: true, pattern };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { blocked: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if a command is allowed by the server-side allowlist.
|
|
66
|
+
* @param {string} command
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
function isAllowedExecCommand(command) {
|
|
70
|
+
const trimmed = (command || '').trim();
|
|
71
|
+
if (!trimmed) return false;
|
|
72
|
+
return ALLOWED_EXEC_PREFIXES.some(p => trimmed.startsWith(p));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Full server-side validation: blocklist + allowlist.
|
|
77
|
+
* Returns { allowed: true } or { allowed: false, reason: string }.
|
|
78
|
+
* @param {string} command
|
|
79
|
+
* @returns {{ allowed: boolean, reason?: string }}
|
|
80
|
+
*/
|
|
81
|
+
function validateExecCommand(command) {
|
|
82
|
+
const trimmed = (command || '').trim();
|
|
83
|
+
if (!trimmed) {
|
|
84
|
+
return { allowed: false, reason: 'Empty command' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const destructive = checkDestructivePatterns(trimmed);
|
|
88
|
+
if (destructive.blocked) {
|
|
89
|
+
return { allowed: false, reason: `Blocked by destructive pattern: ${destructive.pattern}` };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!isAllowedExecCommand(trimmed)) {
|
|
93
|
+
return { allowed: false, reason: `Command not in server-side allowlist: ${trimmed.slice(0, 80)}` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { allowed: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
DESTRUCTIVE_PATTERNS,
|
|
101
|
+
ALLOWED_EXEC_PREFIXES,
|
|
102
|
+
checkDestructivePatterns,
|
|
103
|
+
isAllowedExecCommand,
|
|
104
|
+
validateExecCommand,
|
|
105
|
+
};
|
package/lib/kanban-io.js
CHANGED
|
@@ -28,47 +28,31 @@ const ACTIVE_TASKS_PATH = path.join(
|
|
|
28
28
|
// Prevents lost updates when mesh-bridge and memory-daemon write concurrently.
|
|
29
29
|
// See architecture note above for why this is local-only.
|
|
30
30
|
|
|
31
|
-
function withMkdirLock(filePath, fn) {
|
|
31
|
+
async function withMkdirLock(filePath, fn) {
|
|
32
32
|
const lockDir = filePath + '.lk';
|
|
33
|
-
const maxWait = 5000;
|
|
33
|
+
const maxWait = 5000;
|
|
34
|
+
const pollInterval = 50;
|
|
34
35
|
const start = Date.now();
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
while (true) {
|
|
37
|
+
while (Date.now() - start < maxWait) {
|
|
38
38
|
try {
|
|
39
39
|
fs.mkdirSync(lockDir);
|
|
40
|
-
break; // got the lock
|
|
41
|
-
} catch (err) {
|
|
42
|
-
if (err.code !== 'EEXIST') throw err;
|
|
43
|
-
// Lock held by another process — check for stale lock (>30s)
|
|
44
40
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
fs.rmdirSync(lockDir);
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
} catch { /* stat failed, lock was just released */ continue; }
|
|
52
|
-
|
|
53
|
-
if (Date.now() - start > maxWait) {
|
|
54
|
-
throw new Error(`kanban-io: lock timeout after ${maxWait}ms on ${filePath}`);
|
|
41
|
+
return await fn();
|
|
42
|
+
} finally {
|
|
43
|
+
try { fs.rmdirSync(lockDir); } catch {}
|
|
55
44
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
} catch {
|
|
61
|
-
const end = Date.now() + 10;
|
|
62
|
-
while (Date.now() < end) { /* busy-wait fallback */ }
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code === 'EEXIST') {
|
|
47
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
48
|
+
continue;
|
|
63
49
|
}
|
|
50
|
+
throw err;
|
|
64
51
|
}
|
|
65
52
|
}
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
|
|
69
|
-
} finally {
|
|
70
|
-
try { fs.rmdirSync(lockDir); } catch { /* already released */ }
|
|
71
|
-
}
|
|
53
|
+
// Timeout — force acquire (stale lock)
|
|
54
|
+
try { fs.rmdirSync(lockDir); } catch {}
|
|
55
|
+
return fn();
|
|
72
56
|
}
|
|
73
57
|
|
|
74
58
|
// ── Parser ──────────────────────────────────────────
|
package/lib/llm-providers.js
CHANGED
|
@@ -21,6 +21,19 @@ const path = require('path');
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const os = require('os');
|
|
23
23
|
|
|
24
|
+
// ── Shell Command Security ─────────────────────────
|
|
25
|
+
const SHELL_PROVIDER_ALLOWED_PREFIXES = [
|
|
26
|
+
'npm test', 'npm run', 'node ', 'python ', 'pytest', 'cargo test',
|
|
27
|
+
'go test', 'make', 'jest', 'vitest', 'mocha', 'bash ', 'sh ',
|
|
28
|
+
'cat ', 'echo ', 'ls ', 'grep ', 'find ', 'git '
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function validateShellCommand(cmd) {
|
|
32
|
+
const trimmed = (cmd || '').trim();
|
|
33
|
+
if (!trimmed) return false;
|
|
34
|
+
return SHELL_PROVIDER_ALLOWED_PREFIXES.some(p => trimmed.startsWith(p));
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
// ── Generic Provider Factory ────────────────────────
|
|
25
38
|
// Most agentic coding CLIs follow a similar pattern:
|
|
26
39
|
// binary [prompt-flag] "prompt" [model-flag] model [cwd-flag] dir
|
|
@@ -167,6 +180,9 @@ const PROVIDERS = {
|
|
|
167
180
|
buildArgs(prompt, model, task) {
|
|
168
181
|
// Use task.description (the raw command) if available, fall back to prompt
|
|
169
182
|
const cmd = (task && task.description) ? task.description : prompt;
|
|
183
|
+
if (!validateShellCommand(cmd)) {
|
|
184
|
+
throw new Error(`Shell provider: command blocked by security filter: ${cmd.slice(0, 80)}`);
|
|
185
|
+
}
|
|
170
186
|
return ['-c', cmd];
|
|
171
187
|
},
|
|
172
188
|
cleanEnv(env) {
|
|
@@ -349,14 +349,14 @@ export async function indexWorkspace(db, root, opts = {}) {
|
|
|
349
349
|
const texts = chunks.map(c => c.text);
|
|
350
350
|
const vectors = await embedBatch(texts);
|
|
351
351
|
|
|
352
|
-
//
|
|
352
|
+
// Insert chunks and their vector embeddings
|
|
353
353
|
const doInsert = db.transaction(() => {
|
|
354
354
|
insertDoc.run(file.rel, hash, Date.now(), chunks.length);
|
|
355
355
|
for (let i = 0; i < chunks.length; i++) {
|
|
356
356
|
const snippet = chunks[i].text.slice(0, SNIPPET_LENGTH).replace(/\n/g, ' ');
|
|
357
357
|
const info = insertChunk.run(file.rel, chunks[i].section, chunks[i].text, snippet);
|
|
358
358
|
const vecBuf = Buffer.from(vectors[i].buffer);
|
|
359
|
-
db.prepare(`INSERT INTO chunk_vectors VALUES (
|
|
359
|
+
db.prepare(`INSERT INTO chunk_vectors VALUES (?, ?)`).run(info.lastInsertRowid, vecBuf);
|
|
360
360
|
}
|
|
361
361
|
});
|
|
362
362
|
doInsert();
|
|
@@ -373,6 +373,7 @@ export async function indexWorkspace(db, root, opts = {}) {
|
|
|
373
373
|
// ─── Search Functions ────────────────────────────────────────────────────────
|
|
374
374
|
|
|
375
375
|
export async function semanticSearch(db, query, limit = 10) {
|
|
376
|
+
const safeLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 10)));
|
|
376
377
|
const count = db.prepare('SELECT COUNT(*) as c FROM chunk_vectors').get().c;
|
|
377
378
|
if (count === 0) return [];
|
|
378
379
|
|
|
@@ -388,7 +389,7 @@ export async function semanticSearch(db, query, limit = 10) {
|
|
|
388
389
|
c.snippet
|
|
389
390
|
FROM chunk_vectors cv
|
|
390
391
|
JOIN chunks c ON c.id = cv.rowid
|
|
391
|
-
WHERE embedding MATCH ? AND k = ${
|
|
392
|
+
WHERE embedding MATCH ? AND k = ${safeLimit}
|
|
392
393
|
ORDER BY distance
|
|
393
394
|
`).all(vecBuf);
|
|
394
395
|
|
|
@@ -401,6 +402,7 @@ export async function semanticSearch(db, query, limit = 10) {
|
|
|
401
402
|
}
|
|
402
403
|
|
|
403
404
|
export async function findRelated(db, docPath, limit = 10) {
|
|
405
|
+
const safeLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 10)));
|
|
404
406
|
const chunkIds = db.prepare('SELECT id FROM chunks WHERE doc_path = ?').all(docPath);
|
|
405
407
|
|
|
406
408
|
if (chunkIds.length === 0) {
|
|
@@ -445,7 +447,7 @@ export async function findRelated(db, docPath, limit = 10) {
|
|
|
445
447
|
c.snippet
|
|
446
448
|
FROM chunk_vectors cv
|
|
447
449
|
JOIN chunks c ON c.id = cv.rowid
|
|
448
|
-
WHERE embedding MATCH ? AND k = ${
|
|
450
|
+
WHERE embedding MATCH ? AND k = ${safeLimit * 3}
|
|
449
451
|
ORDER BY distance
|
|
450
452
|
`).all(vecBuf);
|
|
451
453
|
|
|
@@ -459,7 +461,7 @@ export async function findRelated(db, docPath, limit = 10) {
|
|
|
459
461
|
}
|
|
460
462
|
}
|
|
461
463
|
|
|
462
|
-
return [...seen.values()].slice(0,
|
|
464
|
+
return [...seen.values()].slice(0, safeLimit).map(r => ({
|
|
463
465
|
path: r.doc_path,
|
|
464
466
|
section: r.section,
|
|
465
467
|
score: parseFloat((1 - r.distance * r.distance / 2).toFixed(4)),
|
|
@@ -163,7 +163,14 @@ async function startHttp(engine, port, host) {
|
|
|
163
163
|
// Parse body
|
|
164
164
|
const chunks = [];
|
|
165
165
|
for await (const chunk of req) chunks.push(chunk);
|
|
166
|
-
|
|
166
|
+
let body;
|
|
167
|
+
try {
|
|
168
|
+
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
169
|
+
} catch (e) {
|
|
170
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
171
|
+
res.end(JSON.stringify({ error: 'Invalid JSON in request body' }));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
167
174
|
|
|
168
175
|
await transport.handleRequest(req, res, body);
|
|
169
176
|
return;
|