squidclaw 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -208,7 +208,8 @@ export class TelegramManager {
208
208
  const chatId = contactId.replace('tg_', '');
209
209
  const { readFileSync } = await import('fs');
210
210
  const buffer = readFileSync(filePath);
211
- await botInfo.bot.api.sendDocument(chatId, new InputFile(buffer, fileName || 'file'), {
211
+ const { InputFile: IF } = await import("grammy");
212
+ await botInfo.bot.api.sendDocument(chatId, new IF(buffer, fileName || 'file'), {
212
213
  caption: caption || '',
213
214
  });
214
215
  logger.info('telegram', 'Sent document: ' + fileName);
package/lib/engine.js CHANGED
@@ -228,6 +228,28 @@ export class SquidclawEngine {
228
228
  if (pending.c > 0) console.log(` ⏰ Reminders: ${pending.c} pending`);
229
229
  } catch {}
230
230
 
231
+ // Nodes (Paired Devices)
232
+ try {
233
+ const { NodeManager } = await import('./features/nodes.js');
234
+ this.nodeManager = new NodeManager(this);
235
+ await this.nodeManager.startServer(9501);
236
+ const nodeCount = this.nodeManager.list('*').length;
237
+ if (nodeCount > 0) console.log(' 📡 Nodes: ' + nodeCount + ' registered');
238
+ else console.log(' 📡 Nodes: ws://0.0.0.0:9501');
239
+ } catch (err) { logger.error('engine', 'Nodes init failed: ' + err.message); }
240
+
241
+ // Sandbox
242
+ try {
243
+ const { Sandbox } = await import('./features/sandbox.js');
244
+ this.sandbox = new Sandbox({
245
+ timeout: 15000,
246
+ maxMemory: 64,
247
+ maxFiles: 100,
248
+ });
249
+ // Cleanup old files every hour
250
+ setInterval(() => this.sandbox.cleanup(), 3600000);
251
+ } catch (err) { logger.error('engine', 'Sandbox init failed: ' + err.message); }
252
+
231
253
  // Plugins
232
254
  try {
233
255
  const { PluginManager } = await import('./features/plugins.js');
@@ -0,0 +1,315 @@
1
+ /**
2
+ * 🦑 Nodes — Paired Devices
3
+ * Connect phones, PCs, servers, IoT devices
4
+ * Real-time control via WebSocket
5
+ *
6
+ * Pairing: token-based (generate token → enter on device)
7
+ * Communication: WebSocket (ws://host:9501)
8
+ * Commands: camera, screen, location, run, notify, status
9
+ */
10
+
11
+ import { logger } from '../core/logger.js';
12
+ import crypto from 'crypto';
13
+ import { getHome } from '../core/config.js';
14
+
15
+ export class NodeManager {
16
+ constructor(engine) {
17
+ this.engine = engine;
18
+ this.nodes = new Map(); // nodeId -> { info, ws, status, lastSeen }
19
+ this.pendingPairs = new Map(); // token -> { agentId, createdAt, name }
20
+ this.wsServer = null;
21
+ this._initDb();
22
+ }
23
+
24
+ _initDb() {
25
+ this.engine.storage.db.exec(`
26
+ CREATE TABLE IF NOT EXISTS nodes (
27
+ id TEXT PRIMARY KEY,
28
+ agent_id TEXT NOT NULL,
29
+ name TEXT NOT NULL,
30
+ type TEXT DEFAULT 'generic',
31
+ platform TEXT DEFAULT 'unknown',
32
+ capabilities TEXT DEFAULT '[]',
33
+ paired_at TEXT DEFAULT (datetime('now')),
34
+ last_seen TEXT,
35
+ status TEXT DEFAULT 'offline',
36
+ metadata TEXT DEFAULT '{}'
37
+ )
38
+ `);
39
+ }
40
+
41
+ // ── WebSocket Server ──
42
+
43
+ async startServer(port = 9501) {
44
+ try {
45
+ const { WebSocketServer } = await import('ws');
46
+
47
+ this.wsServer = new WebSocketServer({ port, path: '/nodes' });
48
+
49
+ this.wsServer.on('connection', (ws, req) => {
50
+ let nodeId = null;
51
+
52
+ ws.on('message', (data) => {
53
+ try {
54
+ const msg = JSON.parse(data.toString());
55
+ this._handleMessage(ws, msg).then(id => {
56
+ if (id) nodeId = id;
57
+ });
58
+ } catch (err) {
59
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid message' }));
60
+ }
61
+ });
62
+
63
+ ws.on('close', () => {
64
+ if (nodeId) {
65
+ const node = this.nodes.get(nodeId);
66
+ if (node) {
67
+ node.status = 'offline';
68
+ node.ws = null;
69
+ this.engine.storage.db.prepare("UPDATE nodes SET status = 'offline' WHERE id = ?").run(nodeId);
70
+ logger.info('nodes', `Node disconnected: ${node.info?.name || nodeId}`);
71
+ }
72
+ }
73
+ });
74
+
75
+ ws.on('error', () => {});
76
+
77
+ // Send hello
78
+ ws.send(JSON.stringify({ type: 'hello', message: 'Squidclaw Node Server 🦑' }));
79
+ });
80
+
81
+ // Load existing nodes from DB
82
+ const dbNodes = this.engine.storage.db.prepare('SELECT * FROM nodes').all();
83
+ for (const n of dbNodes) {
84
+ this.nodes.set(n.id, {
85
+ info: n,
86
+ ws: null,
87
+ status: 'offline',
88
+ lastSeen: n.last_seen,
89
+ pendingCommands: new Map(),
90
+ });
91
+ }
92
+
93
+ logger.info('nodes', `WebSocket server on port ${port} (${dbNodes.length} registered nodes)`);
94
+ return true;
95
+ } catch (err) {
96
+ logger.error('nodes', `Server failed: ${err.message}`);
97
+ return false;
98
+ }
99
+ }
100
+
101
+ // ── Message Handler ──
102
+
103
+ async _handleMessage(ws, msg) {
104
+ const { type } = msg;
105
+
106
+ // Pairing
107
+ if (type === 'pair') {
108
+ return this._handlePair(ws, msg);
109
+ }
110
+
111
+ // Auth (reconnect with nodeId)
112
+ if (type === 'auth') {
113
+ const node = this.nodes.get(msg.nodeId);
114
+ if (!node) {
115
+ ws.send(JSON.stringify({ type: 'error', message: 'Unknown node' }));
116
+ return null;
117
+ }
118
+ node.ws = ws;
119
+ node.status = 'online';
120
+ node.lastSeen = new Date().toISOString();
121
+ this.engine.storage.db.prepare("UPDATE nodes SET status = 'online', last_seen = datetime('now') WHERE id = ?").run(msg.nodeId);
122
+ ws.send(JSON.stringify({ type: 'auth_ok', nodeId: msg.nodeId }));
123
+ logger.info('nodes', `Node reconnected: ${node.info.name}`);
124
+ return msg.nodeId;
125
+ }
126
+
127
+ // Command response
128
+ if (type === 'response') {
129
+ const node = this.nodes.get(msg.nodeId);
130
+ if (node) {
131
+ const pending = node.pendingCommands?.get(msg.commandId);
132
+ if (pending) {
133
+ pending.resolve(msg.data);
134
+ node.pendingCommands.delete(msg.commandId);
135
+ }
136
+ }
137
+ return msg.nodeId;
138
+ }
139
+
140
+ // Status update
141
+ if (type === 'status') {
142
+ const node = this.nodes.get(msg.nodeId);
143
+ if (node) {
144
+ node.lastSeen = new Date().toISOString();
145
+ if (msg.data) {
146
+ node.info = { ...node.info, ...msg.data };
147
+ }
148
+ this.engine.storage.db.prepare("UPDATE nodes SET last_seen = datetime('now') WHERE id = ?").run(msg.nodeId);
149
+ }
150
+ return msg.nodeId;
151
+ }
152
+
153
+ return null;
154
+ }
155
+
156
+ // ── Pairing ──
157
+
158
+ _handlePair(ws, msg) {
159
+ const token = msg.token;
160
+ const pending = this.pendingPairs.get(token);
161
+
162
+ if (!pending) {
163
+ ws.send(JSON.stringify({ type: 'pair_error', message: 'Invalid or expired token' }));
164
+ return null;
165
+ }
166
+
167
+ const nodeId = 'node_' + crypto.randomBytes(4).toString('hex');
168
+
169
+ // Register node
170
+ const info = {
171
+ id: nodeId,
172
+ agent_id: pending.agentId,
173
+ name: pending.name || msg.name || 'Device',
174
+ type: msg.deviceType || 'generic',
175
+ platform: msg.platform || 'unknown',
176
+ capabilities: JSON.stringify(msg.capabilities || []),
177
+ };
178
+
179
+ this.engine.storage.db.prepare(
180
+ 'INSERT INTO nodes (id, agent_id, name, type, platform, capabilities, status) VALUES (?, ?, ?, ?, ?, ?, ?)'
181
+ ).run(info.id, info.agent_id, info.name, info.type, info.platform, info.capabilities, 'online');
182
+
183
+ this.nodes.set(nodeId, {
184
+ info,
185
+ ws,
186
+ status: 'online',
187
+ lastSeen: new Date().toISOString(),
188
+ pendingCommands: new Map(),
189
+ });
190
+
191
+ this.pendingPairs.delete(token);
192
+
193
+ ws.send(JSON.stringify({ type: 'paired', nodeId, name: info.name }));
194
+ logger.info('nodes', `New node paired: ${info.name} (${nodeId}) type=${info.type}`);
195
+
196
+ return nodeId;
197
+ }
198
+
199
+ // ── Generate pairing token ──
200
+
201
+ generatePairToken(agentId, name = 'Device') {
202
+ const token = crypto.randomBytes(3).toString('hex').toUpperCase(); // 6-char code
203
+ this.pendingPairs.set(token, {
204
+ agentId,
205
+ name,
206
+ createdAt: Date.now(),
207
+ });
208
+
209
+ // Expire after 5 minutes
210
+ setTimeout(() => this.pendingPairs.delete(token), 300000);
211
+
212
+ return token;
213
+ }
214
+
215
+ // ── Send command to node ──
216
+
217
+ async sendCommand(nodeId, command, data = {}, timeout = 30000) {
218
+ const node = this.nodes.get(nodeId);
219
+ if (!node) throw new Error('Node not found: ' + nodeId);
220
+ if (!node.ws || node.status !== 'online') throw new Error('Node offline: ' + (node.info?.name || nodeId));
221
+
222
+ const commandId = 'cmd_' + Date.now().toString(36);
223
+
224
+ return new Promise((resolve, reject) => {
225
+ const timer = setTimeout(() => {
226
+ node.pendingCommands?.delete(commandId);
227
+ reject(new Error('Command timeout'));
228
+ }, timeout);
229
+
230
+ node.pendingCommands = node.pendingCommands || new Map();
231
+ node.pendingCommands.set(commandId, {
232
+ resolve: (data) => { clearTimeout(timer); resolve(data); },
233
+ });
234
+
235
+ node.ws.send(JSON.stringify({
236
+ type: 'command',
237
+ commandId,
238
+ command,
239
+ data,
240
+ }));
241
+ });
242
+ }
243
+
244
+ // ── High-level commands ──
245
+
246
+ async camera(nodeId, options = {}) {
247
+ return this.sendCommand(nodeId, 'camera', {
248
+ facing: options.facing || 'back',
249
+ ...options,
250
+ });
251
+ }
252
+
253
+ async screenshot(nodeId) {
254
+ return this.sendCommand(nodeId, 'screenshot');
255
+ }
256
+
257
+ async location(nodeId) {
258
+ return this.sendCommand(nodeId, 'location');
259
+ }
260
+
261
+ async notify(nodeId, title, body) {
262
+ return this.sendCommand(nodeId, 'notify', { title, body });
263
+ }
264
+
265
+ async run(nodeId, command) {
266
+ return this.sendCommand(nodeId, 'run', { command }, 60000);
267
+ }
268
+
269
+ async screenRecord(nodeId, durationMs = 10000) {
270
+ return this.sendCommand(nodeId, 'screen_record', { durationMs }, durationMs + 10000);
271
+ }
272
+
273
+ // ── List/Status ──
274
+
275
+ list(agentId) {
276
+ return this.engine.storage.db.prepare(
277
+ 'SELECT * FROM nodes WHERE agent_id = ? ORDER BY name'
278
+ ).all(agentId).map(n => {
279
+ const live = this.nodes.get(n.id);
280
+ return {
281
+ ...n,
282
+ capabilities: JSON.parse(n.capabilities || '[]'),
283
+ status: live?.status || 'offline',
284
+ lastSeen: live?.lastSeen || n.last_seen,
285
+ };
286
+ });
287
+ }
288
+
289
+ getNode(nodeId) {
290
+ const db = this.engine.storage.db.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
291
+ if (!db) return null;
292
+ const live = this.nodes.get(nodeId);
293
+ return { ...db, status: live?.status || 'offline', capabilities: JSON.parse(db.capabilities || '[]') };
294
+ }
295
+
296
+ removeNode(nodeId) {
297
+ const node = this.nodes.get(nodeId);
298
+ if (node?.ws) {
299
+ node.ws.send(JSON.stringify({ type: 'unpaired' }));
300
+ node.ws.close();
301
+ }
302
+ this.nodes.delete(nodeId);
303
+ this.engine.storage.db.prepare('DELETE FROM nodes WHERE id = ?').run(nodeId);
304
+ logger.info('nodes', `Removed node: ${nodeId}`);
305
+ }
306
+
307
+ // ── Cleanup ──
308
+
309
+ stop() {
310
+ if (this.wsServer) {
311
+ this.wsServer.close();
312
+ logger.info('nodes', 'WebSocket server stopped');
313
+ }
314
+ }
315
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * 🦑 Proper Sandbox
3
+ * Isolated code execution with resource limits
4
+ * - VM isolation (vm module)
5
+ * - CPU timeout
6
+ * - Memory limits
7
+ * - File system jail
8
+ * - Network restrictions
9
+ * - Safe eval for user code
10
+ */
11
+
12
+ import { logger } from '../core/logger.js';
13
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync, unlinkSync } from 'fs';
14
+ import { join, resolve } from 'path';
15
+ import { execSync, spawn } from 'child_process';
16
+ import vm from 'vm';
17
+
18
+ export class Sandbox {
19
+ constructor(options = {}) {
20
+ this.jailDir = options.jailDir || '/tmp/squidclaw-sandbox';
21
+ this.timeout = options.timeout || 10000; // 10s default
22
+ this.maxMemory = options.maxMemory || 64; // 64MB
23
+ this.maxFileSize = options.maxFileSize || 1024 * 1024; // 1MB
24
+ this.maxFiles = options.maxFiles || 50;
25
+ this.maxOutputSize = options.maxOutputSize || 50000; // 50KB
26
+ this.allowNetwork = options.allowNetwork || false;
27
+
28
+ mkdirSync(this.jailDir, { recursive: true });
29
+
30
+ // Blocked patterns for shell commands
31
+ this.blockedCommands = [
32
+ /rm\s+(-rf?|--recursive)\s+\//, /mkfs/, /dd\s+if=/, /:\(\)\{/,
33
+ /chmod\s+777\s+\//, /chown\s.*\//, /passwd/, /userdel/, /useradd/,
34
+ /shutdown/, /reboot/, /halt/, /poweroff/,
35
+ /iptables/, /ufw\s/, /firewall/,
36
+ /wget.*\|.*sh/, /curl.*\|.*sh/, /eval\s*\(/, // download & execute
37
+ /\/etc\/shadow/, /\/etc\/passwd/,
38
+ /ssh\s/, /scp\s/, /rsync\s/, // no remote access
39
+ /npm\s+install\s+-g/, /pip\s+install/, // no global installs
40
+ /systemctl/, /service\s/, // no service control
41
+ ];
42
+ }
43
+
44
+ // ── JavaScript VM Execution ──
45
+
46
+ evalJS(code, context = {}) {
47
+ const sandbox = {
48
+ console: {
49
+ log: (...args) => { output.push(args.map(String).join(' ')); },
50
+ error: (...args) => { output.push('ERROR: ' + args.map(String).join(' ')); },
51
+ warn: (...args) => { output.push('WARN: ' + args.map(String).join(' ')); },
52
+ },
53
+ Math, Date, JSON, parseInt, parseFloat, isNaN, isFinite,
54
+ String, Number, Boolean, Array, Object, Map, Set, RegExp,
55
+ setTimeout: undefined, setInterval: undefined, // blocked
56
+ fetch: undefined, // blocked unless allowNetwork
57
+ require: undefined, // blocked
58
+ process: { env: {} }, // sanitized
59
+ Buffer: undefined, // blocked
60
+ ...context,
61
+ };
62
+
63
+ const output = [];
64
+
65
+ try {
66
+ const vmContext = vm.createContext(sandbox, {
67
+ codeGeneration: { strings: false, wasm: false },
68
+ });
69
+
70
+ const script = new vm.Script(code, {
71
+ timeout: this.timeout,
72
+ filename: 'sandbox.js',
73
+ });
74
+
75
+ const result = script.runInContext(vmContext, {
76
+ timeout: this.timeout,
77
+ breakOnSigint: true,
78
+ });
79
+
80
+ if (result !== undefined && !output.length) {
81
+ output.push(String(result));
82
+ }
83
+
84
+ const finalOutput = output.join('\n').slice(0, this.maxOutputSize);
85
+ logger.info('sandbox', `JS eval OK (${code.length} chars)`);
86
+ return { success: true, output: finalOutput, type: 'javascript' };
87
+
88
+ } catch (err) {
89
+ const errMsg = err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT'
90
+ ? 'Execution timeout (' + (this.timeout / 1000) + 's limit)'
91
+ : err.message;
92
+ return { success: false, error: errMsg, output: output.join('\n'), type: 'javascript' };
93
+ }
94
+ }
95
+
96
+ // ── Python Execution (process-isolated) ──
97
+
98
+ async runPython(code, options = {}) {
99
+ const filename = 'run_' + Date.now() + '.py';
100
+ const filepath = join(this.jailDir, filename);
101
+ const timeout = options.timeout || this.timeout;
102
+
103
+ // Safety: wrap in resource limits
104
+ const wrappedCode = `
105
+ import sys, os, signal
106
+ signal.alarm(${Math.ceil(timeout / 1000)})
107
+ sys.path = ['.']
108
+ os.chdir('${this.jailDir}')
109
+ ${code}
110
+ `;
111
+
112
+ writeFileSync(filepath, wrappedCode);
113
+
114
+ try {
115
+ const result = await this._execProcess('python3', [filepath], {
116
+ timeout,
117
+ cwd: this.jailDir,
118
+ maxOutput: this.maxOutputSize,
119
+ });
120
+ return { ...result, type: 'python' };
121
+ } finally {
122
+ try { unlinkSync(filepath); } catch {}
123
+ }
124
+ }
125
+
126
+ // ── Shell Execution (sandboxed) ──
127
+
128
+ async runShell(command, options = {}) {
129
+ // Security check
130
+ for (const pattern of this.blockedCommands) {
131
+ if (pattern.test(command)) {
132
+ return { success: false, error: 'Blocked: dangerous command pattern', type: 'shell' };
133
+ }
134
+ }
135
+
136
+ const timeout = options.timeout || this.timeout;
137
+
138
+ try {
139
+ const output = execSync(command, {
140
+ cwd: options.cwd || this.jailDir,
141
+ timeout,
142
+ maxBuffer: this.maxOutputSize,
143
+ encoding: 'utf8',
144
+ env: {
145
+ PATH: '/usr/local/bin:/usr/bin:/bin',
146
+ HOME: this.jailDir,
147
+ LANG: 'en_US.UTF-8',
148
+ // No AWS keys, no tokens, no secrets
149
+ },
150
+ });
151
+
152
+ logger.info('sandbox', `Shell OK: ${command.slice(0, 50)}`);
153
+ return { success: true, output: output.trim().slice(0, this.maxOutputSize), type: 'shell' };
154
+ } catch (err) {
155
+ return {
156
+ success: false,
157
+ output: (err.stdout || '').trim().slice(0, 5000),
158
+ error: (err.stderr || err.message).trim().slice(0, 5000),
159
+ exitCode: err.status || 1,
160
+ type: 'shell',
161
+ };
162
+ }
163
+ }
164
+
165
+ // ── File System (jailed) ──
166
+
167
+ writeFile(name, content) {
168
+ const safePath = this._safePath(name);
169
+
170
+ if (content.length > this.maxFileSize) {
171
+ throw new Error('File too large: ' + (content.length / 1024).toFixed(0) + 'KB (max ' + (this.maxFileSize / 1024) + 'KB)');
172
+ }
173
+
174
+ const fileCount = this._countFiles();
175
+ if (fileCount >= this.maxFiles) {
176
+ throw new Error('Too many files in sandbox (max ' + this.maxFiles + ')');
177
+ }
178
+
179
+ const dir = resolve(safePath, '..');
180
+ mkdirSync(dir, { recursive: true });
181
+ writeFileSync(safePath, content);
182
+ return { path: safePath, size: content.length };
183
+ }
184
+
185
+ readFile(name) {
186
+ const safePath = this._safePath(name);
187
+ if (!existsSync(safePath)) throw new Error('File not found: ' + name);
188
+
189
+ const stat = statSync(safePath);
190
+ if (stat.size > this.maxFileSize) throw new Error('File too large to read');
191
+
192
+ return readFileSync(safePath, 'utf8');
193
+ }
194
+
195
+ listFiles(subdir = '.') {
196
+ const safePath = this._safePath(subdir);
197
+ if (!existsSync(safePath)) return [];
198
+
199
+ return readdirSync(safePath, { withFileTypes: true }).map(d => ({
200
+ name: d.name,
201
+ type: d.isDirectory() ? 'dir' : 'file',
202
+ size: d.isFile() ? statSync(join(safePath, d.name)).size : 0,
203
+ }));
204
+ }
205
+
206
+ deleteFile(name) {
207
+ const safePath = this._safePath(name);
208
+ if (existsSync(safePath)) unlinkSync(safePath);
209
+ }
210
+
211
+ // ── Cleanup ──
212
+
213
+ cleanup() {
214
+ try {
215
+ execSync(`find ${this.jailDir} -type f -mmin +60 -delete 2>/dev/null`, { timeout: 5000 });
216
+ logger.info('sandbox', 'Cleaned old files');
217
+ } catch {}
218
+ }
219
+
220
+ // ── Stats ──
221
+
222
+ getStats() {
223
+ const files = this._countFiles();
224
+ let totalSize = 0;
225
+ try {
226
+ const output = execSync(`du -sb ${this.jailDir} 2>/dev/null`, { encoding: 'utf8' });
227
+ totalSize = parseInt(output.split('\t')[0]) || 0;
228
+ } catch {}
229
+
230
+ return {
231
+ files,
232
+ maxFiles: this.maxFiles,
233
+ totalSize,
234
+ maxFileSize: this.maxFileSize,
235
+ timeout: this.timeout,
236
+ maxMemory: this.maxMemory,
237
+ jailDir: this.jailDir,
238
+ allowNetwork: this.allowNetwork,
239
+ };
240
+ }
241
+
242
+ // ── Internal ──
243
+
244
+ _safePath(name) {
245
+ const resolved = resolve(this.jailDir, name);
246
+ if (!resolved.startsWith(this.jailDir)) {
247
+ throw new Error('Path traversal blocked');
248
+ }
249
+ return resolved;
250
+ }
251
+
252
+ _countFiles() {
253
+ try {
254
+ const output = execSync(`find ${this.jailDir} -type f | wc -l`, { encoding: 'utf8', timeout: 3000 });
255
+ return parseInt(output.trim()) || 0;
256
+ } catch { return 0; }
257
+ }
258
+
259
+ async _execProcess(cmd, args, options = {}) {
260
+ return new Promise((resolve) => {
261
+ let stdout = '';
262
+ let stderr = '';
263
+ const timeout = options.timeout || this.timeout;
264
+ const maxOutput = options.maxOutput || this.maxOutputSize;
265
+
266
+ const proc = spawn(cmd, args, {
267
+ cwd: options.cwd || this.jailDir,
268
+ timeout,
269
+ env: {
270
+ PATH: '/usr/local/bin:/usr/bin:/bin',
271
+ HOME: this.jailDir,
272
+ LANG: 'en_US.UTF-8',
273
+ },
274
+ });
275
+
276
+ const timer = setTimeout(() => {
277
+ proc.kill('SIGKILL');
278
+ resolve({ success: false, error: 'Timeout (' + (timeout / 1000) + 's)', output: stdout.slice(0, maxOutput) });
279
+ }, timeout);
280
+
281
+ proc.stdout.on('data', d => { stdout += d; if (stdout.length > maxOutput) proc.kill(); });
282
+ proc.stderr.on('data', d => { stderr += d; });
283
+
284
+ proc.on('close', (code) => {
285
+ clearTimeout(timer);
286
+ if (code === 0) {
287
+ resolve({ success: true, output: stdout.trim().slice(0, maxOutput) });
288
+ } else {
289
+ resolve({ success: false, output: stdout.trim().slice(0, 5000), error: stderr.trim().slice(0, 5000), exitCode: code });
290
+ }
291
+ });
292
+
293
+ proc.on('error', (err) => {
294
+ clearTimeout(timer);
295
+ resolve({ success: false, error: err.message });
296
+ });
297
+ });
298
+ }
299
+ }
@@ -145,6 +145,96 @@ export async function commandsMiddleware(ctx, next) {
145
145
  return;
146
146
  }
147
147
 
148
+ if (cmd === '/nodes' || cmd === '/devices') {
149
+ if (!ctx.engine.nodeManager) { await ctx.reply('❌ Nodes not available'); return; }
150
+ const args = msg.split(/\s+/).slice(1);
151
+ const sub = args[0];
152
+
153
+ if (!sub || sub === 'list') {
154
+ const nodes = ctx.engine.nodeManager.list(ctx.agentId);
155
+ if (nodes.length === 0) {
156
+ await ctx.reply('📡 No paired devices\n\nUse /nodes pair <name> to add one');
157
+ return;
158
+ }
159
+ const lines = nodes.map(n =>
160
+ (n.status === 'online' ? '🟢' : '🔴') + ' *' + n.name + '* (' + n.type + ')\n ' + n.platform + ' · ' + n.id
161
+ );
162
+ await ctx.reply('📡 *Paired Devices*\n\n' + lines.join('\n\n'));
163
+ return;
164
+ }
165
+
166
+ if (sub === 'pair') {
167
+ const name = args.slice(1).join(' ') || 'My Device';
168
+ const token = ctx.engine.nodeManager.generatePairToken(ctx.agentId, name);
169
+ await ctx.reply('📡 *Pair: ' + name + '*\n\nCode: `' + token + '`\n\nEnter this on the device.\nExpires in 5 minutes.\n\nWebSocket: ws://' + (ctx.engine.config.api?.host || '0.0.0.0') + ':9501/nodes');
170
+ return;
171
+ }
172
+
173
+ if (sub === 'remove') {
174
+ const id = args[1];
175
+ if (!id) { await ctx.reply('Usage: /nodes remove <id>'); return; }
176
+ ctx.engine.nodeManager.removeNode(id);
177
+ await ctx.reply('✅ Device removed');
178
+ return;
179
+ }
180
+
181
+ if (sub === 'notify') {
182
+ const id = args[1];
183
+ const message = args.slice(2).join(' ');
184
+ if (!id || !message) { await ctx.reply('Usage: /nodes notify <id> <message>'); return; }
185
+ try {
186
+ await ctx.engine.nodeManager.notify(id, 'Squidclaw', message);
187
+ await ctx.reply('📨 Notification sent');
188
+ } catch (err) { await ctx.reply('❌ ' + err.message); }
189
+ return;
190
+ }
191
+
192
+ await ctx.reply('📡 *Node Commands*\n\n/nodes — list devices\n/nodes pair <name> — generate pairing code\n/nodes remove <id> — unpair\n/nodes notify <id> <msg> — send notification');
193
+ return;
194
+ }
195
+
196
+ if (cmd === '/sandbox') {
197
+ if (!ctx.engine.sandbox) { await ctx.reply('❌ Sandbox not available'); return; }
198
+ const args = msg.slice(9).trim();
199
+
200
+ if (!args || args === 'stats') {
201
+ const stats = ctx.engine.sandbox.getStats();
202
+ await ctx.reply('🔒 *Sandbox*\n\n📁 Files: ' + stats.files + '/' + stats.maxFiles + '\n💾 Size: ' + (stats.totalSize / 1024).toFixed(0) + ' KB\n⏱️ Timeout: ' + (stats.timeout / 1000) + 's\n🧠 Max memory: ' + stats.maxMemory + ' MB\n🌐 Network: ' + (stats.allowNetwork ? '✅' : '🚫'));
203
+ return;
204
+ }
205
+
206
+ if (args === 'files') {
207
+ const files = ctx.engine.sandbox.listFiles();
208
+ if (files.length === 0) { await ctx.reply('📁 Sandbox is empty'); return; }
209
+ const lines = files.map(f => (f.type === 'dir' ? '📁 ' : '📄 ') + f.name + (f.size ? ' (' + (f.size / 1024).toFixed(1) + ' KB)' : ''));
210
+ await ctx.reply('📁 *Sandbox Files*\n\n' + lines.join('\n'));
211
+ return;
212
+ }
213
+
214
+ if (args === 'clean') {
215
+ ctx.engine.sandbox.cleanup();
216
+ await ctx.reply('🧹 Sandbox cleaned');
217
+ return;
218
+ }
219
+
220
+ if (args.startsWith('js ')) {
221
+ const code = args.slice(3);
222
+ const result = ctx.engine.sandbox.evalJS(code);
223
+ await ctx.reply(result.success ? '```\n' + (result.output || '(no output)') + '\n```' : '❌ ' + result.error);
224
+ return;
225
+ }
226
+
227
+ if (args.startsWith('py ')) {
228
+ const code = args.slice(3);
229
+ const result = await ctx.engine.sandbox.runPython(code);
230
+ await ctx.reply(result.success ? '```\n' + (result.output || '(no output)') + '\n```' : '❌ ' + (result.error || 'Failed'));
231
+ return;
232
+ }
233
+
234
+ await ctx.reply('🔒 *Sandbox Commands*\n\n/sandbox — stats\n/sandbox files — list files\n/sandbox clean — remove old files\n/sandbox js <code> — run JavaScript\n/sandbox py <code> — run Python');
235
+ return;
236
+ }
237
+
148
238
  if (cmd === '/plugins' || cmd === '/plugin') {
149
239
  if (!ctx.engine.plugins) { await ctx.reply('❌ Plugin system not available'); return; }
150
240
  const args = msg.split(/\s+/).slice(1);
@@ -169,6 +169,30 @@ export class ToolRouter {
169
169
  '---TOOL:handoff:reason---',
170
170
  'Transfer the conversation to a human agent. Use when you cannot help further.');
171
171
 
172
+ tools.push('', '### Pair Device',
173
+ '---TOOL:node_pair:device name---',
174
+ 'Generate a pairing code for a new device (phone, PC, server).',
175
+ '', '### List Devices',
176
+ '---TOOL:node_list:all---',
177
+ 'Show all paired devices and their status.',
178
+ '', '### Send to Device',
179
+ '---TOOL:node_cmd:node_id|command|data---',
180
+ 'Send a command to a paired device. Commands: camera, screenshot, location, notify, run.',
181
+ 'Example: ---TOOL:node_cmd:node_abc123|notify|Meeting in 5 minutes!---',
182
+ '', '### Device Camera',
183
+ '---TOOL:node_camera:node_id---',
184
+ 'Take a photo from a paired device camera.',
185
+ '', '### Device Location',
186
+ '---TOOL:node_location:node_id---',
187
+ 'Get GPS location of a paired device.');
188
+
189
+ tools.push('', '### Run JavaScript (Sandboxed)',
190
+ '---TOOL:js:console.log(2 + 2)---',
191
+ 'Execute JavaScript in a secure VM sandbox. No network, no filesystem, no require. Safe for math, logic, data processing.',
192
+ '', '### Sandbox Info',
193
+ '---TOOL:sandbox_info:stats---',
194
+ 'Show sandbox stats (files, size, limits).');
195
+
172
196
  tools.push('', '### Run Command',
173
197
  '---TOOL:exec:ls -la---',
174
198
  'Execute a shell command. Output is returned. Sandboxed for safety.',
@@ -611,10 +635,15 @@ export class ToolRouter {
611
635
  case 'exec':
612
636
  case 'shell':
613
637
  case 'run': {
614
- const { ShellTool } = await import('./shell.js');
615
- const sh = new ShellTool();
616
- const result = sh.exec(toolArg);
617
- toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
638
+ if (this._engine?.sandbox) {
639
+ const result = await this._engine.sandbox.runShell(toolArg);
640
+ toolResult = result.success ? (result.output || '(no output)') : 'Error: ' + (result.error || 'Unknown');
641
+ } else {
642
+ const { ShellTool } = await import('./shell.js');
643
+ const sh = new ShellTool();
644
+ const result = sh.exec(toolArg);
645
+ toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
646
+ }
618
647
  break;
619
648
  }
620
649
  case 'readfile': {
@@ -643,14 +672,95 @@ export class ToolRouter {
643
672
  toolResult = result.error || result.files.map(f => (f.type === 'dir' ? '📁 ' : '📄 ') + f.name).join('\n');
644
673
  break;
645
674
  }
675
+ case 'js':
676
+ case 'javascript':
677
+ case 'eval': {
678
+ if (this._engine?.sandbox) {
679
+ const result = this._engine.sandbox.evalJS(toolArg);
680
+ toolResult = result.success ? (result.output || '(no output)') : 'Error: ' + (result.error || 'Unknown');
681
+ } else {
682
+ toolResult = 'Sandbox not available';
683
+ }
684
+ break;
685
+ }
646
686
  case 'python':
647
687
  case 'py': {
648
- const { ShellTool } = await import('./shell.js');
649
- const sh = new ShellTool();
650
- // Write Python script then execute
651
- sh.writeFile('_run.py', toolArg);
652
- const result = sh.exec('python3 _run.py');
653
- toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
688
+ if (this._engine?.sandbox) {
689
+ const result = await this._engine.sandbox.runPython(toolArg);
690
+ toolResult = result.success ? (result.output || '(no output)') : 'Error: ' + (result.error || 'Unknown');
691
+ } else {
692
+ const { ShellTool } = await import('./shell.js');
693
+ const sh = new ShellTool();
694
+ sh.writeFile('_run.py', toolArg);
695
+ const result = sh.exec('python3 _run.py');
696
+ toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
697
+ }
698
+ break;
699
+ }
700
+ case 'node_pair': {
701
+ if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
702
+ const token = this._engine.nodeManager.generatePairToken(agentId, toolArg || 'Device');
703
+ toolResult = '📡 Pairing code: *' + token + '*\n\nEnter this code on the device to pair.\nExpires in 5 minutes.\n\nConnect to: ws://<server>:9501/nodes';
704
+ break;
705
+ }
706
+ case 'node_list': {
707
+ if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
708
+ const nodes = this._engine.nodeManager.list(agentId);
709
+ if (nodes.length === 0) { toolResult = 'No paired devices. Use node_pair to add one.'; break; }
710
+ toolResult = nodes.map(n =>
711
+ (n.status === 'online' ? '🟢' : '🔴') + ' ' + n.name + ' (' + n.type + ')\n ID: ' + n.id + '\n Platform: ' + n.platform + '\n Last seen: ' + (n.lastSeen || 'never')
712
+ ).join('\n\n');
713
+ break;
714
+ }
715
+ case 'node_cmd': {
716
+ if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
717
+ try {
718
+ const parts = toolArg.split('|').map(p => p.trim());
719
+ const [nodeId, command, ...dataParts] = parts;
720
+ const data = dataParts.join('|');
721
+
722
+ if (command === 'notify') {
723
+ await this._engine.nodeManager.notify(nodeId, 'Squidclaw', data);
724
+ toolResult = '📨 Notification sent to device';
725
+ } else if (command === 'run') {
726
+ const result = await this._engine.nodeManager.run(nodeId, data);
727
+ toolResult = 'Output: ' + JSON.stringify(result);
728
+ } else {
729
+ const result = await this._engine.nodeManager.sendCommand(nodeId, command, { data });
730
+ toolResult = JSON.stringify(result);
731
+ }
732
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
733
+ break;
734
+ }
735
+ case 'node_camera': {
736
+ if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
737
+ try {
738
+ const result = await this._engine.nodeManager.camera(toolArg.trim());
739
+ toolResult = 'Camera captured: ' + JSON.stringify(result);
740
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
741
+ break;
742
+ }
743
+ case 'node_location': {
744
+ if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
745
+ try {
746
+ const result = await this._engine.nodeManager.location(toolArg.trim());
747
+ toolResult = 'Location: ' + JSON.stringify(result);
748
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
749
+ break;
750
+ }
751
+ case 'sandbox_info': {
752
+ if (this._engine?.sandbox) {
753
+ const stats = this._engine.sandbox.getStats();
754
+ toolResult = '🔒 Sandbox Stats:\n' +
755
+ '📁 Files: ' + stats.files + '/' + stats.maxFiles + '\n' +
756
+ '💾 Size: ' + (stats.totalSize / 1024).toFixed(0) + ' KB\n' +
757
+ '⏱️ Timeout: ' + (stats.timeout / 1000) + 's\n' +
758
+ '🧠 Max memory: ' + stats.maxMemory + ' MB\n' +
759
+ '🌐 Network: ' + (stats.allowNetwork ? 'allowed' : 'blocked') + '\n' +
760
+ '📂 Jail: ' + stats.jailDir;
761
+ } else {
762
+ toolResult = 'Sandbox not available';
763
+ }
654
764
  break;
655
765
  }
656
766
  case 'spawn': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "🦑 AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {
@@ -56,6 +56,7 @@
56
56
  "qrcode-terminal": "^0.12.0",
57
57
  "sharp": "^0.34.5",
58
58
  "undici": "^7.22.0",
59
+ "ws": "^8.19.0",
59
60
  "yaml": "^2.8.2",
60
61
  "zod": "^4.3.6"
61
62
  }