chainlesschain 0.37.12 → 0.40.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/package.json +3 -2
- package/src/commands/agent.js +7 -1
- package/src/commands/ask.js +24 -9
- package/src/commands/chat.js +7 -1
- package/src/commands/cli-anything.js +266 -0
- package/src/commands/compliance.js +216 -0
- package/src/commands/dao.js +312 -0
- package/src/commands/dlp.js +278 -0
- package/src/commands/evomap.js +558 -0
- package/src/commands/hardening.js +230 -0
- package/src/commands/matrix.js +168 -0
- package/src/commands/nostr.js +185 -0
- package/src/commands/pqc.js +162 -0
- package/src/commands/scim.js +218 -0
- package/src/commands/serve.js +109 -0
- package/src/commands/siem.js +156 -0
- package/src/commands/social.js +480 -0
- package/src/commands/terraform.js +148 -0
- package/src/constants.js +1 -0
- package/src/index.js +60 -0
- package/src/lib/autonomous-agent.js +487 -0
- package/src/lib/cli-anything-bridge.js +379 -0
- package/src/lib/cli-context-engineering.js +472 -0
- package/src/lib/compliance-manager.js +290 -0
- package/src/lib/content-recommender.js +205 -0
- package/src/lib/dao-governance.js +296 -0
- package/src/lib/dlp-engine.js +304 -0
- package/src/lib/evomap-client.js +135 -0
- package/src/lib/evomap-federation.js +240 -0
- package/src/lib/evomap-governance.js +250 -0
- package/src/lib/evomap-manager.js +227 -0
- package/src/lib/git-integration.js +1 -1
- package/src/lib/hardening-manager.js +275 -0
- package/src/lib/llm-providers.js +14 -1
- package/src/lib/matrix-bridge.js +196 -0
- package/src/lib/nostr-bridge.js +195 -0
- package/src/lib/permanent-memory.js +370 -0
- package/src/lib/plan-mode.js +211 -0
- package/src/lib/pqc-manager.js +196 -0
- package/src/lib/scim-manager.js +212 -0
- package/src/lib/session-manager.js +38 -0
- package/src/lib/siem-exporter.js +137 -0
- package/src/lib/social-manager.js +283 -0
- package/src/lib/task-model-selector.js +232 -0
- package/src/lib/terraform-manager.js +201 -0
- package/src/lib/ws-server.js +474 -0
- package/src/repl/agent-repl.js +796 -41
- package/src/repl/chat-repl.js +14 -6
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChainlessChain WebSocket Server
|
|
3
|
+
*
|
|
4
|
+
* Exposes CLI commands over WebSocket for remote access by IDE plugins,
|
|
5
|
+
* web frontends, automation scripts, etc. Commands are executed by spawning
|
|
6
|
+
* child processes — all 60+ CLI commands are available immediately.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EventEmitter } from "node:events";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { WebSocketServer } from "ws";
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
|
|
18
|
+
/** Absolute path to the CLI entry point */
|
|
19
|
+
const BIN_PATH = join(__dirname, "..", "..", "bin", "chainlesschain.js");
|
|
20
|
+
|
|
21
|
+
/** Commands that must not be executed via WebSocket */
|
|
22
|
+
const BLOCKED_COMMANDS = new Set(["serve", "chat", "agent", "setup"]);
|
|
23
|
+
|
|
24
|
+
/** Heartbeat interval (ms) */
|
|
25
|
+
const HEARTBEAT_INTERVAL = 30_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tokenize a command string into an array of arguments.
|
|
29
|
+
* Handles double-quoted and single-quoted strings. Does NOT invoke a shell.
|
|
30
|
+
*/
|
|
31
|
+
export function tokenizeCommand(input) {
|
|
32
|
+
const args = [];
|
|
33
|
+
let current = "";
|
|
34
|
+
let inDouble = false;
|
|
35
|
+
let inSingle = false;
|
|
36
|
+
let escape = false;
|
|
37
|
+
|
|
38
|
+
for (const ch of input) {
|
|
39
|
+
if (escape) {
|
|
40
|
+
current += ch;
|
|
41
|
+
escape = false;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (ch === "\\" && inDouble) {
|
|
45
|
+
escape = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (ch === '"' && !inSingle) {
|
|
49
|
+
inDouble = !inDouble;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (ch === "'" && !inDouble) {
|
|
53
|
+
inSingle = !inSingle;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if ((ch === " " || ch === "\t") && !inDouble && !inSingle) {
|
|
57
|
+
if (current.length > 0) {
|
|
58
|
+
args.push(current);
|
|
59
|
+
current = "";
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
current += ch;
|
|
64
|
+
}
|
|
65
|
+
if (current.length > 0) {
|
|
66
|
+
args.push(current);
|
|
67
|
+
}
|
|
68
|
+
return args;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class ChainlessChainWSServer extends EventEmitter {
|
|
72
|
+
/**
|
|
73
|
+
* @param {object} options
|
|
74
|
+
* @param {number} [options.port=18800]
|
|
75
|
+
* @param {string} [options.host="127.0.0.1"]
|
|
76
|
+
* @param {string} [options.token] - If set, clients must authenticate first
|
|
77
|
+
* @param {number} [options.maxConnections=10]
|
|
78
|
+
* @param {number} [options.timeout=30000] - Command execution timeout (ms)
|
|
79
|
+
*/
|
|
80
|
+
constructor(options = {}) {
|
|
81
|
+
super();
|
|
82
|
+
this.port = options.port || 18800;
|
|
83
|
+
this.host = options.host || "127.0.0.1";
|
|
84
|
+
this.token = options.token || null;
|
|
85
|
+
this.maxConnections = options.maxConnections || 10;
|
|
86
|
+
this.timeout = options.timeout || 30000;
|
|
87
|
+
|
|
88
|
+
/** @type {WebSocketServer|null} */
|
|
89
|
+
this.wss = null;
|
|
90
|
+
|
|
91
|
+
/** Connected clients: clientId → { ws, authenticated, connectedAt } */
|
|
92
|
+
this.clients = new Map();
|
|
93
|
+
|
|
94
|
+
/** Running child processes: requestId → ChildProcess */
|
|
95
|
+
this.processes = new Map();
|
|
96
|
+
|
|
97
|
+
this._heartbeatTimer = null;
|
|
98
|
+
this._clientCounter = 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Start the WebSocket server */
|
|
102
|
+
start() {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
this.wss = new WebSocketServer({
|
|
105
|
+
port: this.port,
|
|
106
|
+
host: this.host,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
this.wss.on("listening", () => {
|
|
110
|
+
this._startHeartbeat();
|
|
111
|
+
this.emit("listening", { port: this.port, host: this.host });
|
|
112
|
+
resolve();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
this.wss.on("error", (err) => {
|
|
116
|
+
this.emit("error", err);
|
|
117
|
+
reject(err);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this.wss.on("connection", (ws, req) => this._handleConnection(ws, req));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Stop the server and clean up */
|
|
125
|
+
async stop() {
|
|
126
|
+
if (this._heartbeatTimer) {
|
|
127
|
+
clearInterval(this._heartbeatTimer);
|
|
128
|
+
this._heartbeatTimer = null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Kill all running child processes
|
|
132
|
+
for (const [id, child] of this.processes) {
|
|
133
|
+
try {
|
|
134
|
+
child.kill("SIGTERM");
|
|
135
|
+
} catch (_err) {
|
|
136
|
+
// Process may have already exited
|
|
137
|
+
}
|
|
138
|
+
this.processes.delete(id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Close all client connections
|
|
142
|
+
for (const [, client] of this.clients) {
|
|
143
|
+
try {
|
|
144
|
+
client.ws.close(1001, "Server shutting down");
|
|
145
|
+
} catch (_err) {
|
|
146
|
+
// Connection may already be closed
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
this.clients.clear();
|
|
150
|
+
|
|
151
|
+
// Close the server
|
|
152
|
+
if (this.wss) {
|
|
153
|
+
await new Promise((resolve) => {
|
|
154
|
+
this.wss.close(() => resolve());
|
|
155
|
+
});
|
|
156
|
+
this.wss = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.emit("stopped");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @private */
|
|
163
|
+
_handleConnection(ws, req) {
|
|
164
|
+
if (this.clients.size >= this.maxConnections) {
|
|
165
|
+
ws.close(1013, "Max connections reached");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const clientId = `client-${++this._clientCounter}`;
|
|
170
|
+
const clientIp =
|
|
171
|
+
req.socket.remoteAddress || req.headers["x-forwarded-for"] || "unknown";
|
|
172
|
+
|
|
173
|
+
this.clients.set(clientId, {
|
|
174
|
+
ws,
|
|
175
|
+
authenticated: !this.token, // If no token required, auto-authenticated
|
|
176
|
+
connectedAt: Date.now(),
|
|
177
|
+
ip: clientIp,
|
|
178
|
+
alive: true,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
this.emit("connection", { clientId, ip: clientIp });
|
|
182
|
+
|
|
183
|
+
ws.on("message", (data) => {
|
|
184
|
+
try {
|
|
185
|
+
const message = JSON.parse(data.toString("utf8"));
|
|
186
|
+
this._handleMessage(clientId, ws, message);
|
|
187
|
+
} catch (_err) {
|
|
188
|
+
this._send(ws, {
|
|
189
|
+
type: "error",
|
|
190
|
+
code: "INVALID_JSON",
|
|
191
|
+
message: "Failed to parse message as JSON",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
ws.on("close", () => {
|
|
197
|
+
this.clients.delete(clientId);
|
|
198
|
+
this.emit("disconnection", { clientId });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
ws.on("pong", () => {
|
|
202
|
+
const client = this.clients.get(clientId);
|
|
203
|
+
if (client) client.alive = true;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** @private */
|
|
208
|
+
_handleMessage(clientId, ws, message) {
|
|
209
|
+
const { id, type } = message;
|
|
210
|
+
|
|
211
|
+
if (!id) {
|
|
212
|
+
this._send(ws, {
|
|
213
|
+
type: "error",
|
|
214
|
+
code: "MISSING_ID",
|
|
215
|
+
message: 'Message must include an "id" field',
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check authentication
|
|
221
|
+
const client = this.clients.get(clientId);
|
|
222
|
+
if (this.token && !client.authenticated && type !== "auth") {
|
|
223
|
+
this._send(ws, {
|
|
224
|
+
id,
|
|
225
|
+
type: "error",
|
|
226
|
+
code: "AUTH_REQUIRED",
|
|
227
|
+
message: "Authentication required. Send an auth message first.",
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
switch (type) {
|
|
233
|
+
case "auth":
|
|
234
|
+
this._handleAuth(clientId, ws, message);
|
|
235
|
+
break;
|
|
236
|
+
case "ping":
|
|
237
|
+
this._send(ws, { id, type: "pong", serverTime: Date.now() });
|
|
238
|
+
break;
|
|
239
|
+
case "execute":
|
|
240
|
+
this._executeCommand(id, ws, message.command, false);
|
|
241
|
+
break;
|
|
242
|
+
case "stream":
|
|
243
|
+
this._executeCommand(id, ws, message.command, true);
|
|
244
|
+
break;
|
|
245
|
+
case "cancel":
|
|
246
|
+
this._cancelRequest(id, ws);
|
|
247
|
+
break;
|
|
248
|
+
default:
|
|
249
|
+
this._send(ws, {
|
|
250
|
+
id,
|
|
251
|
+
type: "error",
|
|
252
|
+
code: "UNKNOWN_TYPE",
|
|
253
|
+
message: `Unknown message type: ${type}`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @private */
|
|
259
|
+
_handleAuth(clientId, ws, message) {
|
|
260
|
+
const { id, token } = message;
|
|
261
|
+
const success = token === this.token;
|
|
262
|
+
const client = this.clients.get(clientId);
|
|
263
|
+
|
|
264
|
+
if (success && client) {
|
|
265
|
+
client.authenticated = true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this._send(ws, {
|
|
269
|
+
id,
|
|
270
|
+
type: "auth-result",
|
|
271
|
+
success,
|
|
272
|
+
...(success ? {} : { message: "Invalid token" }),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (!success) {
|
|
276
|
+
// Disconnect after failed auth
|
|
277
|
+
setTimeout(() => ws.close(4001, "Authentication failed"), 100);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** @private */
|
|
282
|
+
_executeCommand(id, ws, command, stream) {
|
|
283
|
+
if (!command || typeof command !== "string") {
|
|
284
|
+
this._send(ws, {
|
|
285
|
+
id,
|
|
286
|
+
type: "error",
|
|
287
|
+
code: "INVALID_COMMAND",
|
|
288
|
+
message: "Command must be a non-empty string",
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const args = tokenizeCommand(command.trim());
|
|
294
|
+
if (args.length === 0) {
|
|
295
|
+
this._send(ws, {
|
|
296
|
+
id,
|
|
297
|
+
type: "error",
|
|
298
|
+
code: "INVALID_COMMAND",
|
|
299
|
+
message: "Empty command",
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Block dangerous/interactive commands
|
|
305
|
+
const baseCmd = args[0];
|
|
306
|
+
if (BLOCKED_COMMANDS.has(baseCmd)) {
|
|
307
|
+
this._send(ws, {
|
|
308
|
+
id,
|
|
309
|
+
type: "error",
|
|
310
|
+
code: "COMMAND_BLOCKED",
|
|
311
|
+
message: `Command "${baseCmd}" cannot be executed via WebSocket (interactive or recursive)`,
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const child = spawn(process.execPath, [BIN_PATH, ...args], {
|
|
317
|
+
env: {
|
|
318
|
+
...process.env,
|
|
319
|
+
FORCE_COLOR: "0",
|
|
320
|
+
NO_SPINNER: "1",
|
|
321
|
+
},
|
|
322
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
323
|
+
windowsHide: true,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
this.processes.set(id, child);
|
|
327
|
+
this.emit("command:start", { id, command, stream });
|
|
328
|
+
|
|
329
|
+
// Timeout handling
|
|
330
|
+
const timer = setTimeout(() => {
|
|
331
|
+
if (this.processes.has(id)) {
|
|
332
|
+
try {
|
|
333
|
+
child.kill("SIGTERM");
|
|
334
|
+
} catch (_err) {
|
|
335
|
+
// Process may have already exited
|
|
336
|
+
}
|
|
337
|
+
this.processes.delete(id);
|
|
338
|
+
this._send(ws, {
|
|
339
|
+
id,
|
|
340
|
+
type: "error",
|
|
341
|
+
code: "COMMAND_TIMEOUT",
|
|
342
|
+
message: `Command timed out after ${this.timeout}ms`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}, this.timeout);
|
|
346
|
+
|
|
347
|
+
if (stream) {
|
|
348
|
+
// Stream mode: send chunks as they arrive
|
|
349
|
+
child.stdout.on("data", (data) => {
|
|
350
|
+
this._send(ws, {
|
|
351
|
+
id,
|
|
352
|
+
type: "stream-data",
|
|
353
|
+
channel: "stdout",
|
|
354
|
+
data: data.toString("utf8"),
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
child.stderr.on("data", (data) => {
|
|
359
|
+
this._send(ws, {
|
|
360
|
+
id,
|
|
361
|
+
type: "stream-data",
|
|
362
|
+
channel: "stderr",
|
|
363
|
+
data: data.toString("utf8"),
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
child.on("close", (exitCode) => {
|
|
368
|
+
clearTimeout(timer);
|
|
369
|
+
this.processes.delete(id);
|
|
370
|
+
this._send(ws, {
|
|
371
|
+
id,
|
|
372
|
+
type: "stream-end",
|
|
373
|
+
exitCode: exitCode ?? 1,
|
|
374
|
+
});
|
|
375
|
+
this.emit("command:end", { id, exitCode });
|
|
376
|
+
});
|
|
377
|
+
} else {
|
|
378
|
+
// Buffered mode: collect all output then send result
|
|
379
|
+
const stdoutChunks = [];
|
|
380
|
+
const stderrChunks = [];
|
|
381
|
+
|
|
382
|
+
child.stdout.on("data", (data) => stdoutChunks.push(data));
|
|
383
|
+
child.stderr.on("data", (data) => stderrChunks.push(data));
|
|
384
|
+
|
|
385
|
+
child.on("close", (exitCode) => {
|
|
386
|
+
clearTimeout(timer);
|
|
387
|
+
this.processes.delete(id);
|
|
388
|
+
|
|
389
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
390
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
391
|
+
|
|
392
|
+
this._send(ws, {
|
|
393
|
+
id,
|
|
394
|
+
type: "result",
|
|
395
|
+
success: exitCode === 0,
|
|
396
|
+
exitCode: exitCode ?? 1,
|
|
397
|
+
stdout,
|
|
398
|
+
stderr,
|
|
399
|
+
});
|
|
400
|
+
this.emit("command:end", { id, exitCode });
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
child.on("error", (err) => {
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
this.processes.delete(id);
|
|
407
|
+
this._send(ws, {
|
|
408
|
+
id,
|
|
409
|
+
type: "error",
|
|
410
|
+
code: "SPAWN_ERROR",
|
|
411
|
+
message: err.message,
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** @private */
|
|
417
|
+
_cancelRequest(id, ws) {
|
|
418
|
+
const child = this.processes.get(id);
|
|
419
|
+
if (child) {
|
|
420
|
+
try {
|
|
421
|
+
child.kill("SIGTERM");
|
|
422
|
+
} catch (_err) {
|
|
423
|
+
// Process may have already exited
|
|
424
|
+
}
|
|
425
|
+
this.processes.delete(id);
|
|
426
|
+
this._send(ws, {
|
|
427
|
+
id,
|
|
428
|
+
type: "result",
|
|
429
|
+
success: false,
|
|
430
|
+
exitCode: -1,
|
|
431
|
+
stdout: "",
|
|
432
|
+
stderr: "Cancelled by client",
|
|
433
|
+
});
|
|
434
|
+
} else {
|
|
435
|
+
this._send(ws, {
|
|
436
|
+
id,
|
|
437
|
+
type: "error",
|
|
438
|
+
code: "NOT_FOUND",
|
|
439
|
+
message: `No running command with id "${id}"`,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** @private — ping/pong heartbeat to detect dead connections */
|
|
445
|
+
_startHeartbeat() {
|
|
446
|
+
this._heartbeatTimer = setInterval(() => {
|
|
447
|
+
for (const [clientId, client] of this.clients) {
|
|
448
|
+
if (!client.alive) {
|
|
449
|
+
client.ws.terminate();
|
|
450
|
+
this.clients.delete(clientId);
|
|
451
|
+
this.emit("disconnection", { clientId, reason: "heartbeat timeout" });
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
client.alive = false;
|
|
455
|
+
try {
|
|
456
|
+
client.ws.ping();
|
|
457
|
+
} catch (_err) {
|
|
458
|
+
// Connection may be closing
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}, HEARTBEAT_INTERVAL);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** @private — safe JSON send */
|
|
465
|
+
_send(ws, data) {
|
|
466
|
+
if (ws.readyState === ws.OPEN) {
|
|
467
|
+
try {
|
|
468
|
+
ws.send(JSON.stringify(data));
|
|
469
|
+
} catch (_err) {
|
|
470
|
+
// Connection may have just closed
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|