discoclaw 0.2.4 → 0.3.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.
Files changed (46) hide show
  1. package/.context/pa.md +1 -1
  2. package/.context/runtime.md +48 -4
  3. package/.env.example +6 -0
  4. package/.env.example.full +7 -0
  5. package/README.md +5 -1
  6. package/dist/config.js +2 -0
  7. package/dist/cron/cron-sync-coordinator.js +4 -0
  8. package/dist/cron/cron-sync-coordinator.test.js +8 -0
  9. package/dist/cron/executor.js +36 -1
  10. package/dist/cron/executor.test.js +157 -0
  11. package/dist/cron/forum-sync.js +47 -0
  12. package/dist/cron/forum-sync.test.js +234 -0
  13. package/dist/cron/run-stats.js +10 -3
  14. package/dist/cron/run-stats.test.js +67 -3
  15. package/dist/discord/actions-config.js +41 -8
  16. package/dist/discord/actions-config.test.js +130 -8
  17. package/dist/discord/actions-crons.js +18 -0
  18. package/dist/discord/actions-crons.test.js +12 -0
  19. package/dist/discord/models-command.js +5 -0
  20. package/dist/index.js +28 -0
  21. package/dist/mcp-detect.js +74 -0
  22. package/dist/mcp-detect.test.js +160 -0
  23. package/dist/runtime/openai-compat.js +224 -90
  24. package/dist/runtime/openai-compat.test.js +409 -2
  25. package/dist/runtime/openai-tool-exec.js +433 -0
  26. package/dist/runtime/openai-tool-exec.test.js +267 -0
  27. package/dist/runtime/openai-tool-schemas.js +174 -0
  28. package/dist/runtime/openai-tool-schemas.test.js +74 -0
  29. package/dist/runtime/tools/fs-glob.js +102 -0
  30. package/dist/runtime/tools/fs-glob.test.js +67 -0
  31. package/dist/runtime/tools/fs-read-file.js +49 -0
  32. package/dist/runtime/tools/fs-read-file.test.js +51 -0
  33. package/dist/runtime/tools/fs-realpath.js +51 -0
  34. package/dist/runtime/tools/fs-realpath.test.js +72 -0
  35. package/dist/runtime/tools/fs-write-file.js +45 -0
  36. package/dist/runtime/tools/fs-write-file.test.js +56 -0
  37. package/dist/runtime/tools/image-download.js +138 -0
  38. package/dist/runtime/tools/image-download.test.js +106 -0
  39. package/dist/runtime/tools/path-security.js +72 -0
  40. package/dist/runtime/tools/types.js +4 -0
  41. package/dist/workspace-bootstrap.js +0 -1
  42. package/dist/workspace-bootstrap.test.js +0 -2
  43. package/package.json +1 -1
  44. package/templates/mcp.json +8 -0
  45. package/templates/workspace/TOOLS.md +70 -1
  46. package/templates/workspace/HEARTBEAT.md +0 -10
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Server-side tool execution handlers for OpenAI function-calling tools.
3
+ *
4
+ * Each handler receives parsed arguments and returns { result, ok }.
5
+ * All handlers catch exceptions — they never throw.
6
+ */
7
+ import { execFile } from 'node:child_process';
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { promisify } from 'node:util';
11
+ const execFileAsync = promisify(execFile);
12
+ // ── Constants ────────────────────────────────────────────────────────
13
+ const MAX_READ_BYTES = 1 * 1024 * 1024; // 1 MB
14
+ const BASH_TIMEOUT_MS = 30_000;
15
+ const BASH_MAX_OUTPUT = 100 * 1024; // 100 KB per stream
16
+ const FETCH_TIMEOUT_MS = 15_000;
17
+ const FETCH_MAX_BYTES = 512 * 1024; // 512 KB
18
+ /** RFC 1918 / private / loopback prefixes for SSRF protection. */
19
+ const PRIVATE_IP_PREFIXES = [
20
+ '10.',
21
+ '172.16.', '172.17.', '172.18.', '172.19.',
22
+ '172.20.', '172.21.', '172.22.', '172.23.',
23
+ '172.24.', '172.25.', '172.26.', '172.27.',
24
+ '172.28.', '172.29.', '172.30.', '172.31.',
25
+ '192.168.',
26
+ '127.',
27
+ '0.',
28
+ '169.254.',
29
+ ];
30
+ const LOCALHOST_HOSTNAMES = new Set(['localhost', '[::1]']);
31
+ // ── Path security ────────────────────────────────────────────────────
32
+ /**
33
+ * Canonicalize allowed roots once (resolves symlinks in the roots themselves).
34
+ * Falls back to path.resolve if the root dir doesn't exist yet.
35
+ */
36
+ async function canonicalizeRoots(roots) {
37
+ const canonical = [];
38
+ for (const root of roots) {
39
+ try {
40
+ canonical.push(await fs.realpath(root));
41
+ }
42
+ catch {
43
+ canonical.push(path.resolve(root));
44
+ }
45
+ }
46
+ return canonical;
47
+ }
48
+ /**
49
+ * Verify that `targetPath` falls under at least one allowed root.
50
+ * Uses fs.realpath to resolve symlinks, preventing symlink escapes.
51
+ * When the target (or its parent) doesn't exist, walks up the directory tree
52
+ * to find an existing ancestor and validates that.
53
+ */
54
+ async function assertPathAllowed(targetPath, allowedRoots, checkParent = false) {
55
+ const canonicalRoots = await canonicalizeRoots(allowedRoots);
56
+ let toCheck = checkParent ? path.dirname(targetPath) : targetPath;
57
+ // Walk up to the nearest existing ancestor for realpath resolution
58
+ let canonical;
59
+ let current = toCheck;
60
+ for (;;) {
61
+ try {
62
+ canonical = await fs.realpath(current);
63
+ break;
64
+ }
65
+ catch (err) {
66
+ if (err.code === 'ENOENT') {
67
+ const parent = path.dirname(current);
68
+ if (parent === current) {
69
+ // Reached filesystem root without finding an existing dir
70
+ throw new Error(`Path not accessible: ${toCheck}`);
71
+ }
72
+ current = parent;
73
+ continue;
74
+ }
75
+ throw new Error(`Path not accessible: ${toCheck}`);
76
+ }
77
+ }
78
+ // Reconstruct the full canonical path by appending the non-existing suffix
79
+ const suffix = path.relative(current, toCheck);
80
+ if (suffix && suffix !== '.') {
81
+ canonical = path.join(canonical, suffix);
82
+ }
83
+ const allowed = canonicalRoots.some((root) => canonical === root || canonical.startsWith(root + path.sep));
84
+ if (!allowed) {
85
+ throw new Error(`Path outside allowed roots: ${targetPath}`);
86
+ }
87
+ }
88
+ /**
89
+ * Resolve a file_path argument against the first allowed root,
90
+ * then validate it falls within allowed roots.
91
+ */
92
+ async function resolveAndCheck(filePath, allowedRoots, checkParent = false) {
93
+ // Resolve relative paths against the first root
94
+ const resolved = path.resolve(allowedRoots[0], filePath);
95
+ await assertPathAllowed(resolved, allowedRoots, checkParent);
96
+ return resolved;
97
+ }
98
+ // ── Individual handlers ──────────────────────────────────────────────
99
+ async function handleReadFile(args, allowedRoots) {
100
+ const filePath = args.file_path;
101
+ if (!filePath)
102
+ return { result: 'file_path is required', ok: false };
103
+ const resolved = await resolveAndCheck(filePath, allowedRoots);
104
+ const stat = await fs.stat(resolved);
105
+ if (stat.size > MAX_READ_BYTES) {
106
+ return { result: `File too large: ${stat.size} bytes (max ${MAX_READ_BYTES})`, ok: false };
107
+ }
108
+ const content = await fs.readFile(resolved, 'utf-8');
109
+ const lines = content.split('\n');
110
+ const offset = typeof args.offset === 'number' ? Math.max(0, args.offset - 1) : 0; // 1-based to 0-based
111
+ const limit = typeof args.limit === 'number' ? args.limit : lines.length;
112
+ const sliced = lines.slice(offset, offset + limit);
113
+ return { result: sliced.join('\n'), ok: true };
114
+ }
115
+ async function handleWriteFile(args, allowedRoots) {
116
+ const filePath = args.file_path;
117
+ const content = args.content;
118
+ if (!filePath)
119
+ return { result: 'file_path is required', ok: false };
120
+ if (typeof content !== 'string')
121
+ return { result: 'content is required', ok: false };
122
+ const resolved = await resolveAndCheck(filePath, allowedRoots, true);
123
+ // Ensure parent directory exists
124
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
125
+ await fs.writeFile(resolved, content, 'utf-8');
126
+ return { result: `Wrote ${Buffer.byteLength(content)} bytes to ${resolved}`, ok: true };
127
+ }
128
+ async function handleEditFile(args, allowedRoots) {
129
+ const filePath = args.file_path;
130
+ const oldText = args.old_string;
131
+ const newText = args.new_string;
132
+ const replaceAll = args.replace_all === true;
133
+ if (!filePath)
134
+ return { result: 'file_path is required', ok: false };
135
+ if (typeof oldText !== 'string')
136
+ return { result: 'old_string is required', ok: false };
137
+ if (typeof newText !== 'string')
138
+ return { result: 'new_string is required', ok: false };
139
+ const resolved = await resolveAndCheck(filePath, allowedRoots);
140
+ const content = await fs.readFile(resolved, 'utf-8');
141
+ if (replaceAll) {
142
+ if (!content.includes(oldText)) {
143
+ return { result: 'old_string not found in file', ok: false };
144
+ }
145
+ const updated = content.replaceAll(oldText, newText);
146
+ await fs.writeFile(resolved, updated, 'utf-8');
147
+ return { result: 'All occurrences replaced', ok: true };
148
+ }
149
+ // Count occurrences for unique-match requirement
150
+ let count = 0;
151
+ let idx = 0;
152
+ while ((idx = content.indexOf(oldText, idx)) !== -1) {
153
+ count++;
154
+ idx += oldText.length;
155
+ }
156
+ if (count === 0) {
157
+ return { result: 'old_string not found in file', ok: false };
158
+ }
159
+ if (count > 1) {
160
+ return { result: `old_string found ${count} times (must be unique); use replace_all or provide more context`, ok: false };
161
+ }
162
+ const updated = content.replace(oldText, newText);
163
+ await fs.writeFile(resolved, updated, 'utf-8');
164
+ return { result: 'Edit applied', ok: true };
165
+ }
166
+ async function handleListFiles(args, allowedRoots) {
167
+ const pattern = args.pattern;
168
+ const searchPath = args.path;
169
+ if (!pattern)
170
+ return { result: 'pattern is required', ok: false };
171
+ const baseDir = searchPath
172
+ ? await resolveAndCheck(searchPath, allowedRoots)
173
+ : allowedRoots[0];
174
+ // Use recursive readdir + minimatch-style matching via fs.glob (Node 22+)
175
+ // Fall back to recursive readdir if fs.glob is not available
176
+ const matches = [];
177
+ if (typeof fs.glob === 'function') {
178
+ // Node 22+ fs.glob
179
+ const globFn = fs.glob;
180
+ for await (const entry of globFn(pattern, { cwd: baseDir })) {
181
+ matches.push(entry);
182
+ if (matches.length >= 1000)
183
+ break; // safety cap
184
+ }
185
+ }
186
+ else {
187
+ // Fallback: recursive readdir + simple glob matching
188
+ const allFiles = await collectFiles(baseDir, baseDir, 5000);
189
+ const { minimatch } = await simpleMinimatch();
190
+ for (const file of allFiles) {
191
+ if (minimatch(file, pattern)) {
192
+ matches.push(file);
193
+ if (matches.length >= 1000)
194
+ break;
195
+ }
196
+ }
197
+ }
198
+ if (matches.length === 0) {
199
+ return { result: 'No files matched', ok: true };
200
+ }
201
+ return { result: matches.join('\n'), ok: true };
202
+ }
203
+ /** Recursively collect relative file paths. */
204
+ async function collectFiles(dir, base, limit) {
205
+ const results = [];
206
+ const entries = await fs.readdir(dir, { withFileTypes: true });
207
+ for (const entry of entries) {
208
+ if (results.length >= limit)
209
+ break;
210
+ const full = path.join(dir, entry.name);
211
+ const rel = path.relative(base, full);
212
+ if (entry.isDirectory()) {
213
+ // Skip hidden dirs and node_modules
214
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
215
+ continue;
216
+ const sub = await collectFiles(full, base, limit - results.length);
217
+ results.push(...sub);
218
+ }
219
+ else {
220
+ results.push(rel);
221
+ }
222
+ }
223
+ return results;
224
+ }
225
+ /** Lazy simple minimatch: basic glob matching without external deps. */
226
+ function simpleMinimatch() {
227
+ return {
228
+ minimatch(file, pattern) {
229
+ // Convert glob to regex: ** → any path, * → any non-sep, ? → single char
230
+ let re = pattern
231
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex specials (except * and ?)
232
+ .replace(/\*\*/g, '\0DOUBLESTAR\0')
233
+ .replace(/\*/g, '[^/]*')
234
+ .replace(/\0DOUBLESTAR\0/g, '.*')
235
+ .replace(/\?/g, '[^/]');
236
+ re = '^' + re + '$';
237
+ return new RegExp(re).test(file);
238
+ },
239
+ };
240
+ }
241
+ async function handleSearchContent(args, allowedRoots) {
242
+ const pattern = args.pattern;
243
+ const searchPath = args.path;
244
+ const glob = args.glob;
245
+ const caseInsensitive = args.case_insensitive === true;
246
+ if (!pattern)
247
+ return { result: 'pattern is required', ok: false };
248
+ const baseDir = searchPath
249
+ ? await resolveAndCheck(searchPath, allowedRoots)
250
+ : allowedRoots[0];
251
+ // Try rg first, fall back to grep if rg isn't installed
252
+ const rgArgs = ['--no-heading', '--line-number', '--color', 'never'];
253
+ if (caseInsensitive)
254
+ rgArgs.push('-i');
255
+ if (glob)
256
+ rgArgs.push('--glob', glob);
257
+ rgArgs.push('--', pattern, baseDir);
258
+ try {
259
+ const { stdout } = await execFileAsync('rg', rgArgs, {
260
+ timeout: BASH_TIMEOUT_MS,
261
+ maxBuffer: BASH_MAX_OUTPUT,
262
+ });
263
+ return { result: stdout || 'No matches found', ok: true };
264
+ }
265
+ catch (err) {
266
+ const e = err;
267
+ // rg exits 1 when no matches found — not an error
268
+ if (e.code === 1) {
269
+ return { result: 'No matches found', ok: true };
270
+ }
271
+ // rg not installed — fall back to grep
272
+ if (e.message?.includes('ENOENT')) {
273
+ return searchWithGrep(pattern, baseDir, { caseInsensitive, glob });
274
+ }
275
+ return { result: e.stderr || e.message || 'search failed', ok: false };
276
+ }
277
+ }
278
+ /** Fallback grep-based search when ripgrep is not available. */
279
+ async function searchWithGrep(pattern, baseDir, opts) {
280
+ const grepArgs = ['-rn', '--color=never'];
281
+ if (opts.caseInsensitive)
282
+ grepArgs.push('-i');
283
+ if (opts.glob) {
284
+ grepArgs.push('--include', opts.glob);
285
+ }
286
+ grepArgs.push('--', pattern, baseDir);
287
+ try {
288
+ const { stdout } = await execFileAsync('grep', grepArgs, {
289
+ timeout: BASH_TIMEOUT_MS,
290
+ maxBuffer: BASH_MAX_OUTPUT,
291
+ });
292
+ return { result: stdout || 'No matches found', ok: true };
293
+ }
294
+ catch (err) {
295
+ const e = err;
296
+ // grep exits 1 when no matches found — not an error
297
+ if (e.code === 1) {
298
+ return { result: 'No matches found', ok: true };
299
+ }
300
+ return { result: e.stderr || e.message || 'search failed', ok: false };
301
+ }
302
+ }
303
+ async function handleBash(args, allowedRoots) {
304
+ const command = args.command;
305
+ if (!command)
306
+ return { result: 'command is required', ok: false };
307
+ try {
308
+ const { stdout, stderr } = await execFileAsync('/bin/bash', ['-c', command], {
309
+ cwd: allowedRoots[0],
310
+ timeout: BASH_TIMEOUT_MS,
311
+ maxBuffer: BASH_MAX_OUTPUT,
312
+ });
313
+ const parts = [];
314
+ if (stdout)
315
+ parts.push(stdout);
316
+ if (stderr)
317
+ parts.push(`[stderr]\n${stderr}`);
318
+ return { result: parts.join('\n') || '(no output)', ok: true };
319
+ }
320
+ catch (err) {
321
+ const e = err;
322
+ if (e.killed) {
323
+ return { result: 'Command timed out (30s limit)', ok: false };
324
+ }
325
+ const parts = [];
326
+ if (e.stdout)
327
+ parts.push(e.stdout);
328
+ if (e.stderr)
329
+ parts.push(e.stderr);
330
+ return { result: parts.join('\n') || e.message || 'command failed', ok: false };
331
+ }
332
+ }
333
+ async function handleWebFetch(args) {
334
+ const url = args.url;
335
+ if (!url)
336
+ return { result: 'url is required', ok: false };
337
+ // Validate URL
338
+ let parsed;
339
+ try {
340
+ parsed = new URL(url);
341
+ }
342
+ catch {
343
+ return { result: 'Invalid URL', ok: false };
344
+ }
345
+ // HTTPS only
346
+ if (parsed.protocol !== 'https:') {
347
+ return { result: `Blocked: only HTTPS URLs are allowed (got ${parsed.protocol})`, ok: false };
348
+ }
349
+ // Block private/loopback IPs and localhost
350
+ const hostname = parsed.hostname;
351
+ if (LOCALHOST_HOSTNAMES.has(hostname)) {
352
+ return { result: 'Blocked: localhost URLs are not allowed', ok: false };
353
+ }
354
+ if (PRIVATE_IP_PREFIXES.some((prefix) => hostname.startsWith(prefix))) {
355
+ return { result: 'Blocked: private/internal IP addresses are not allowed', ok: false };
356
+ }
357
+ // IPv6 loopback
358
+ if (hostname === '::1' || hostname === '[::1]') {
359
+ return { result: 'Blocked: loopback addresses are not allowed', ok: false };
360
+ }
361
+ try {
362
+ const response = await fetch(url, {
363
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
364
+ redirect: 'error',
365
+ });
366
+ if (!response.ok) {
367
+ return { result: `HTTP ${response.status} ${response.statusText}`, ok: false };
368
+ }
369
+ const buffer = Buffer.from(await response.arrayBuffer());
370
+ if (buffer.length > FETCH_MAX_BYTES) {
371
+ return {
372
+ result: `Response too large: ${buffer.length} bytes (max ${FETCH_MAX_BYTES})`,
373
+ ok: false,
374
+ };
375
+ }
376
+ return { result: buffer.toString('utf-8'), ok: true };
377
+ }
378
+ catch (err) {
379
+ const e = err instanceof Error ? err : null;
380
+ if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
381
+ return { result: 'Request timed out (15s limit)', ok: false };
382
+ }
383
+ if (e?.name === 'TypeError' && String(e.message).includes('redirect')) {
384
+ return { result: 'Blocked: unexpected redirect', ok: false };
385
+ }
386
+ return { result: e?.message || 'fetch failed', ok: false };
387
+ }
388
+ }
389
+ function handleWebSearch() {
390
+ return {
391
+ result: 'web_search not available — requires search API key configuration (not yet implemented)',
392
+ ok: false,
393
+ };
394
+ }
395
+ // ── Dispatcher ───────────────────────────────────────────────────────
396
+ const HANDLERS = {
397
+ read_file: handleReadFile,
398
+ write_file: handleWriteFile,
399
+ edit_file: handleEditFile,
400
+ list_files: handleListFiles,
401
+ search_content: handleSearchContent,
402
+ bash: handleBash,
403
+ web_fetch: (args) => handleWebFetch(args),
404
+ web_search: () => handleWebSearch(),
405
+ };
406
+ /**
407
+ * Execute an OpenAI function-calling tool by name.
408
+ *
409
+ * @param name OpenAI function name (e.g. 'read_file')
410
+ * @param args Parsed arguments from the function call
411
+ * @param allowedRoots Directories the tool is allowed to access (cwd + addDirs)
412
+ * @param log Optional logging function
413
+ */
414
+ export async function executeToolCall(name, args, allowedRoots, log) {
415
+ if (allowedRoots.length === 0) {
416
+ return { result: 'No allowed roots configured', ok: false };
417
+ }
418
+ const handler = HANDLERS[name];
419
+ if (!handler) {
420
+ return { result: `Unknown tool: ${name}`, ok: false };
421
+ }
422
+ try {
423
+ log?.(`tool:${name} start`);
424
+ const result = await handler(args, allowedRoots, log);
425
+ log?.(`tool:${name} done ok=${result.ok}`);
426
+ return result;
427
+ }
428
+ catch (err) {
429
+ const message = err instanceof Error ? err.message : String(err);
430
+ log?.(`tool:${name} error: ${message}`);
431
+ return { result: message, ok: false };
432
+ }
433
+ }