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.
- package/.context/pa.md +1 -1
- package/.context/runtime.md +48 -4
- package/.env.example +6 -0
- package/.env.example.full +7 -0
- package/README.md +5 -1
- package/dist/config.js +2 -0
- package/dist/cron/cron-sync-coordinator.js +4 -0
- package/dist/cron/cron-sync-coordinator.test.js +8 -0
- package/dist/cron/executor.js +36 -1
- package/dist/cron/executor.test.js +157 -0
- package/dist/cron/forum-sync.js +47 -0
- package/dist/cron/forum-sync.test.js +234 -0
- package/dist/cron/run-stats.js +10 -3
- package/dist/cron/run-stats.test.js +67 -3
- package/dist/discord/actions-config.js +41 -8
- package/dist/discord/actions-config.test.js +130 -8
- package/dist/discord/actions-crons.js +18 -0
- package/dist/discord/actions-crons.test.js +12 -0
- package/dist/discord/models-command.js +5 -0
- package/dist/index.js +28 -0
- package/dist/mcp-detect.js +74 -0
- package/dist/mcp-detect.test.js +160 -0
- package/dist/runtime/openai-compat.js +224 -90
- package/dist/runtime/openai-compat.test.js +409 -2
- package/dist/runtime/openai-tool-exec.js +433 -0
- package/dist/runtime/openai-tool-exec.test.js +267 -0
- package/dist/runtime/openai-tool-schemas.js +174 -0
- package/dist/runtime/openai-tool-schemas.test.js +74 -0
- package/dist/runtime/tools/fs-glob.js +102 -0
- package/dist/runtime/tools/fs-glob.test.js +67 -0
- package/dist/runtime/tools/fs-read-file.js +49 -0
- package/dist/runtime/tools/fs-read-file.test.js +51 -0
- package/dist/runtime/tools/fs-realpath.js +51 -0
- package/dist/runtime/tools/fs-realpath.test.js +72 -0
- package/dist/runtime/tools/fs-write-file.js +45 -0
- package/dist/runtime/tools/fs-write-file.test.js +56 -0
- package/dist/runtime/tools/image-download.js +138 -0
- package/dist/runtime/tools/image-download.test.js +106 -0
- package/dist/runtime/tools/path-security.js +72 -0
- package/dist/runtime/tools/types.js +4 -0
- package/dist/workspace-bootstrap.js +0 -1
- package/dist/workspace-bootstrap.test.js +0 -2
- package/package.json +1 -1
- package/templates/mcp.json +8 -0
- package/templates/workspace/TOOLS.md +70 -1
- package/templates/workspace/HEARTBEAT.md +0 -10
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { executeToolCall } from './openai-tool-exec.js';
|
|
6
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
7
|
+
let tmpDir;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-tool-exec-'));
|
|
10
|
+
});
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
// ── read_file ────────────────────────────────────────────────────────
|
|
15
|
+
describe('read_file', () => {
|
|
16
|
+
it('reads an existing file', async () => {
|
|
17
|
+
const filePath = path.join(tmpDir, 'hello.txt');
|
|
18
|
+
await fs.writeFile(filePath, 'hello world\n');
|
|
19
|
+
const r = await executeToolCall('read_file', { file_path: filePath }, [tmpDir]);
|
|
20
|
+
expect(r.ok).toBe(true);
|
|
21
|
+
expect(r.result).toBe('hello world\n');
|
|
22
|
+
});
|
|
23
|
+
it('returns error for nonexistent file', async () => {
|
|
24
|
+
const filePath = path.join(tmpDir, 'nope.txt');
|
|
25
|
+
const r = await executeToolCall('read_file', { file_path: filePath }, [tmpDir]);
|
|
26
|
+
expect(r.ok).toBe(false);
|
|
27
|
+
expect(r.result).toMatch(/ENOENT|no such file|not accessible/i);
|
|
28
|
+
});
|
|
29
|
+
it('reads with offset and limit', async () => {
|
|
30
|
+
const filePath = path.join(tmpDir, 'lines.txt');
|
|
31
|
+
await fs.writeFile(filePath, 'line1\nline2\nline3\nline4\nline5\n');
|
|
32
|
+
const r = await executeToolCall('read_file', { file_path: filePath, offset: 2, limit: 2 }, [tmpDir]);
|
|
33
|
+
expect(r.ok).toBe(true);
|
|
34
|
+
expect(r.result).toBe('line2\nline3');
|
|
35
|
+
});
|
|
36
|
+
it('returns error when file_path is missing', async () => {
|
|
37
|
+
const r = await executeToolCall('read_file', {}, [tmpDir]);
|
|
38
|
+
expect(r.ok).toBe(false);
|
|
39
|
+
expect(r.result).toContain('file_path');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
// ── write_file ───────────────────────────────────────────────────────
|
|
43
|
+
describe('write_file', () => {
|
|
44
|
+
it('writes a new file and creates parent directories', async () => {
|
|
45
|
+
const filePath = path.join(tmpDir, 'sub', 'dir', 'output.txt');
|
|
46
|
+
const r = await executeToolCall('write_file', { file_path: filePath, content: 'created!' }, [tmpDir]);
|
|
47
|
+
expect(r.ok).toBe(true);
|
|
48
|
+
const contents = await fs.readFile(filePath, 'utf-8');
|
|
49
|
+
expect(contents).toBe('created!');
|
|
50
|
+
});
|
|
51
|
+
it('overwrites an existing file', async () => {
|
|
52
|
+
const filePath = path.join(tmpDir, 'existing.txt');
|
|
53
|
+
await fs.writeFile(filePath, 'old content');
|
|
54
|
+
const r = await executeToolCall('write_file', { file_path: filePath, content: 'new content' }, [tmpDir]);
|
|
55
|
+
expect(r.ok).toBe(true);
|
|
56
|
+
const contents = await fs.readFile(filePath, 'utf-8');
|
|
57
|
+
expect(contents).toBe('new content');
|
|
58
|
+
});
|
|
59
|
+
it('returns error when content is missing', async () => {
|
|
60
|
+
const r = await executeToolCall('write_file', { file_path: path.join(tmpDir, 'x.txt') }, [tmpDir]);
|
|
61
|
+
expect(r.ok).toBe(false);
|
|
62
|
+
expect(r.result).toContain('content');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
// ── edit_file ────────────────────────────────────────────────────────
|
|
66
|
+
describe('edit_file', () => {
|
|
67
|
+
it('replaces a unique match', async () => {
|
|
68
|
+
const filePath = path.join(tmpDir, 'code.ts');
|
|
69
|
+
await fs.writeFile(filePath, 'const x = 1;\nconst y = 2;\n');
|
|
70
|
+
const r = await executeToolCall('edit_file', { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 42;' }, [tmpDir]);
|
|
71
|
+
expect(r.ok).toBe(true);
|
|
72
|
+
const updated = await fs.readFile(filePath, 'utf-8');
|
|
73
|
+
expect(updated).toBe('const x = 42;\nconst y = 2;\n');
|
|
74
|
+
});
|
|
75
|
+
it('fails when old_string not found', async () => {
|
|
76
|
+
const filePath = path.join(tmpDir, 'code.ts');
|
|
77
|
+
await fs.writeFile(filePath, 'const x = 1;\n');
|
|
78
|
+
const r = await executeToolCall('edit_file', { file_path: filePath, old_string: 'nonexistent', new_string: 'replaced' }, [tmpDir]);
|
|
79
|
+
expect(r.ok).toBe(false);
|
|
80
|
+
expect(r.result).toContain('not found');
|
|
81
|
+
});
|
|
82
|
+
it('fails when old_string has multiple matches (without replace_all)', async () => {
|
|
83
|
+
const filePath = path.join(tmpDir, 'code.ts');
|
|
84
|
+
await fs.writeFile(filePath, 'foo\nfoo\nbar\n');
|
|
85
|
+
const r = await executeToolCall('edit_file', { file_path: filePath, old_string: 'foo', new_string: 'baz' }, [tmpDir]);
|
|
86
|
+
expect(r.ok).toBe(false);
|
|
87
|
+
expect(r.result).toContain('2 times');
|
|
88
|
+
});
|
|
89
|
+
it('replace_all replaces all occurrences', async () => {
|
|
90
|
+
const filePath = path.join(tmpDir, 'code.ts');
|
|
91
|
+
await fs.writeFile(filePath, 'foo\nfoo\nbar\n');
|
|
92
|
+
const r = await executeToolCall('edit_file', { file_path: filePath, old_string: 'foo', new_string: 'baz', replace_all: true }, [tmpDir]);
|
|
93
|
+
expect(r.ok).toBe(true);
|
|
94
|
+
const updated = await fs.readFile(filePath, 'utf-8');
|
|
95
|
+
expect(updated).toBe('baz\nbaz\nbar\n');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
// ── list_files ───────────────────────────────────────────────────────
|
|
99
|
+
describe('list_files', () => {
|
|
100
|
+
it('finds files matching a glob pattern', async () => {
|
|
101
|
+
await fs.writeFile(path.join(tmpDir, 'a.ts'), '');
|
|
102
|
+
await fs.writeFile(path.join(tmpDir, 'b.ts'), '');
|
|
103
|
+
await fs.writeFile(path.join(tmpDir, 'c.js'), '');
|
|
104
|
+
const r = await executeToolCall('list_files', { pattern: '*.ts', path: tmpDir }, [tmpDir]);
|
|
105
|
+
expect(r.ok).toBe(true);
|
|
106
|
+
expect(r.result).toContain('a.ts');
|
|
107
|
+
expect(r.result).toContain('b.ts');
|
|
108
|
+
expect(r.result).not.toContain('c.js');
|
|
109
|
+
});
|
|
110
|
+
it('returns message when no files match', async () => {
|
|
111
|
+
const r = await executeToolCall('list_files', { pattern: '*.xyz', path: tmpDir }, [tmpDir]);
|
|
112
|
+
expect(r.ok).toBe(true);
|
|
113
|
+
expect(r.result).toContain('No files matched');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
// ── search_content ───────────────────────────────────────────────────
|
|
117
|
+
describe('search_content', () => {
|
|
118
|
+
it('finds content matching a pattern', async () => {
|
|
119
|
+
await fs.writeFile(path.join(tmpDir, 'file.txt'), 'hello world\ngoodbye world\n');
|
|
120
|
+
const r = await executeToolCall('search_content', { pattern: 'hello', path: tmpDir }, [tmpDir]);
|
|
121
|
+
expect(r.ok).toBe(true);
|
|
122
|
+
expect(r.result).toContain('hello');
|
|
123
|
+
});
|
|
124
|
+
it('returns no matches message for missing pattern', async () => {
|
|
125
|
+
await fs.writeFile(path.join(tmpDir, 'file.txt'), 'hello\n');
|
|
126
|
+
const r = await executeToolCall('search_content', { pattern: 'zzzznotfound', path: tmpDir }, [tmpDir]);
|
|
127
|
+
expect(r.ok).toBe(true);
|
|
128
|
+
expect(r.result).toContain('No matches');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
// ── bash ─────────────────────────────────────────────────────────────
|
|
132
|
+
describe('bash', () => {
|
|
133
|
+
it('executes a simple echo command', async () => {
|
|
134
|
+
const r = await executeToolCall('bash', { command: 'echo hello' }, [tmpDir]);
|
|
135
|
+
expect(r.ok).toBe(true);
|
|
136
|
+
expect(r.result).toContain('hello');
|
|
137
|
+
});
|
|
138
|
+
it('returns error on nonzero exit', async () => {
|
|
139
|
+
const r = await executeToolCall('bash', { command: 'exit 1' }, [tmpDir]);
|
|
140
|
+
expect(r.ok).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
it('uses first allowed root as cwd', async () => {
|
|
143
|
+
const r = await executeToolCall('bash', { command: 'pwd' }, [tmpDir]);
|
|
144
|
+
expect(r.ok).toBe(true);
|
|
145
|
+
// The cwd should be the tmpDir (resolve symlinks for comparison)
|
|
146
|
+
const realTmpDir = await fs.realpath(tmpDir);
|
|
147
|
+
expect(r.result.trim()).toBe(realTmpDir);
|
|
148
|
+
});
|
|
149
|
+
it('times out on long-running commands', async () => {
|
|
150
|
+
// Use a very short timeout via the handler's internal timeout — we test
|
|
151
|
+
// the mechanism by running a command that hangs, but we can't easily
|
|
152
|
+
// override the 30s const. Instead test that a fast-exit command works
|
|
153
|
+
// and trust the execFile timeout mechanism. A full timeout test would
|
|
154
|
+
// need 30+ seconds which is too slow for unit tests.
|
|
155
|
+
const r = await executeToolCall('bash', { command: 'echo fast' }, [tmpDir]);
|
|
156
|
+
expect(r.ok).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
// ── web_fetch ────────────────────────────────────────────────────────
|
|
160
|
+
describe('web_fetch', () => {
|
|
161
|
+
const originalFetch = globalThis.fetch;
|
|
162
|
+
afterEach(() => {
|
|
163
|
+
globalThis.fetch = originalFetch;
|
|
164
|
+
});
|
|
165
|
+
it('fetches HTTPS URL successfully', async () => {
|
|
166
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response('page content', { status: 200 }));
|
|
167
|
+
const r = await executeToolCall('web_fetch', { url: 'https://example.com/page' }, [tmpDir]);
|
|
168
|
+
expect(r.ok).toBe(true);
|
|
169
|
+
expect(r.result).toBe('page content');
|
|
170
|
+
});
|
|
171
|
+
it('rejects HTTP (non-HTTPS) URLs', async () => {
|
|
172
|
+
const r = await executeToolCall('web_fetch', { url: 'http://example.com/page' }, [tmpDir]);
|
|
173
|
+
expect(r.ok).toBe(false);
|
|
174
|
+
expect(r.result).toContain('HTTPS');
|
|
175
|
+
});
|
|
176
|
+
it('rejects private IP addresses (10.x)', async () => {
|
|
177
|
+
const r = await executeToolCall('web_fetch', { url: 'https://10.0.0.1/internal' }, [tmpDir]);
|
|
178
|
+
expect(r.ok).toBe(false);
|
|
179
|
+
expect(r.result).toContain('private');
|
|
180
|
+
});
|
|
181
|
+
it('rejects private IP addresses (192.168.x)', async () => {
|
|
182
|
+
const r = await executeToolCall('web_fetch', { url: 'https://192.168.1.1/internal' }, [tmpDir]);
|
|
183
|
+
expect(r.ok).toBe(false);
|
|
184
|
+
expect(r.result).toContain('private');
|
|
185
|
+
});
|
|
186
|
+
it('rejects localhost', async () => {
|
|
187
|
+
const r = await executeToolCall('web_fetch', { url: 'https://localhost/internal' }, [tmpDir]);
|
|
188
|
+
expect(r.ok).toBe(false);
|
|
189
|
+
expect(r.result).toContain('localhost');
|
|
190
|
+
});
|
|
191
|
+
it('rejects loopback IP', async () => {
|
|
192
|
+
const r = await executeToolCall('web_fetch', { url: 'https://127.0.0.1/internal' }, [tmpDir]);
|
|
193
|
+
expect(r.ok).toBe(false);
|
|
194
|
+
expect(r.result).toContain('private');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
// ── web_search ───────────────────────────────────────────────────────
|
|
198
|
+
describe('web_search', () => {
|
|
199
|
+
it('returns not available stub', async () => {
|
|
200
|
+
const r = await executeToolCall('web_search', { query: 'test' }, [tmpDir]);
|
|
201
|
+
expect(r.ok).toBe(false);
|
|
202
|
+
expect(r.result).toContain('web_search not available');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
// ── Security: path traversal ─────────────────────────────────────────
|
|
206
|
+
describe('path security', () => {
|
|
207
|
+
it('rejects ../ traversal outside allowed roots', async () => {
|
|
208
|
+
const filePath = path.join(tmpDir, '..', 'etc', 'passwd');
|
|
209
|
+
const r = await executeToolCall('read_file', { file_path: filePath }, [tmpDir]);
|
|
210
|
+
expect(r.ok).toBe(false);
|
|
211
|
+
expect(r.result).toMatch(/outside allowed roots|not accessible/i);
|
|
212
|
+
});
|
|
213
|
+
it('rejects absolute path outside allowed roots', async () => {
|
|
214
|
+
const r = await executeToolCall('read_file', { file_path: '/etc/hostname' }, [tmpDir]);
|
|
215
|
+
expect(r.ok).toBe(false);
|
|
216
|
+
expect(r.result).toMatch(/outside allowed roots|not accessible/i);
|
|
217
|
+
});
|
|
218
|
+
it('rejects symlink pointing outside allowed roots', async () => {
|
|
219
|
+
// Create a temp file outside the allowed root
|
|
220
|
+
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-outside-'));
|
|
221
|
+
const outsideFile = path.join(outsideDir, 'secret.txt');
|
|
222
|
+
await fs.writeFile(outsideFile, 'secret data');
|
|
223
|
+
// Create symlink inside allowed root pointing outside
|
|
224
|
+
const symlinkPath = path.join(tmpDir, 'escape-link');
|
|
225
|
+
await fs.symlink(outsideFile, symlinkPath);
|
|
226
|
+
const r = await executeToolCall('read_file', { file_path: symlinkPath }, [tmpDir]);
|
|
227
|
+
expect(r.ok).toBe(false);
|
|
228
|
+
expect(r.result).toMatch(/outside allowed roots|not accessible/i);
|
|
229
|
+
// Clean up
|
|
230
|
+
await fs.rm(outsideDir, { recursive: true, force: true });
|
|
231
|
+
});
|
|
232
|
+
it('rejects write_file outside allowed roots', async () => {
|
|
233
|
+
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-outside-'));
|
|
234
|
+
const filePath = path.join(outsideDir, 'injected.txt');
|
|
235
|
+
const r = await executeToolCall('write_file', { file_path: filePath, content: 'injected' }, [tmpDir]);
|
|
236
|
+
expect(r.ok).toBe(false);
|
|
237
|
+
expect(r.result).toMatch(/outside allowed roots|not accessible/i);
|
|
238
|
+
// File should not have been created
|
|
239
|
+
await expect(fs.access(filePath)).rejects.toThrow();
|
|
240
|
+
await fs.rm(outsideDir, { recursive: true, force: true });
|
|
241
|
+
});
|
|
242
|
+
it('allows access with multiple allowed roots', async () => {
|
|
243
|
+
const secondRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-root2-'));
|
|
244
|
+
const filePath = path.join(secondRoot, 'allowed.txt');
|
|
245
|
+
await fs.writeFile(filePath, 'allowed content');
|
|
246
|
+
const r = await executeToolCall('read_file', { file_path: filePath }, [tmpDir, secondRoot]);
|
|
247
|
+
expect(r.ok).toBe(true);
|
|
248
|
+
expect(r.result).toBe('allowed content');
|
|
249
|
+
await fs.rm(secondRoot, { recursive: true, force: true });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
// ── Unknown tool ─────────────────────────────────────────────────────
|
|
253
|
+
describe('unknown tool', () => {
|
|
254
|
+
it('returns error for unknown tool name', async () => {
|
|
255
|
+
const r = await executeToolCall('nonexistent_tool', {}, [tmpDir]);
|
|
256
|
+
expect(r.ok).toBe(false);
|
|
257
|
+
expect(r.result).toContain('Unknown tool');
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
// ── Empty allowed roots ──────────────────────────────────────────────
|
|
261
|
+
describe('empty allowed roots', () => {
|
|
262
|
+
it('returns error when no roots are configured', async () => {
|
|
263
|
+
const r = await executeToolCall('read_file', { file_path: '/etc/hostname' }, []);
|
|
264
|
+
expect(r.ok).toBe(false);
|
|
265
|
+
expect(r.result).toContain('No allowed roots');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI function-calling tool definitions for discoclaw tools.
|
|
3
|
+
*
|
|
4
|
+
* Maps internal tool names (Read, Write, …) to OpenAI function names
|
|
5
|
+
* (read_file, write_file, …) and provides JSON Schema parameter definitions.
|
|
6
|
+
*/
|
|
7
|
+
/** Discoclaw tool name → OpenAI function name */
|
|
8
|
+
const DISCO_TO_OPENAI_NAME = {
|
|
9
|
+
Read: 'read_file',
|
|
10
|
+
Write: 'write_file',
|
|
11
|
+
Edit: 'edit_file',
|
|
12
|
+
Glob: 'list_files',
|
|
13
|
+
Grep: 'search_content',
|
|
14
|
+
Bash: 'bash',
|
|
15
|
+
WebSearch: 'web_search',
|
|
16
|
+
WebFetch: 'web_fetch',
|
|
17
|
+
};
|
|
18
|
+
/** OpenAI function name → discoclaw tool name (for dispatching tool results) */
|
|
19
|
+
export const OPENAI_TO_DISCO_NAME = Object.fromEntries(Object.entries(DISCO_TO_OPENAI_NAME).map(([disco, openai]) => [openai, disco]));
|
|
20
|
+
const TOOL_DEFS = {
|
|
21
|
+
Read: {
|
|
22
|
+
type: 'function',
|
|
23
|
+
function: {
|
|
24
|
+
name: 'read_file',
|
|
25
|
+
description: 'Read the contents of a file at the given path.',
|
|
26
|
+
parameters: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
file_path: { type: 'string', description: 'Absolute path to the file to read.' },
|
|
30
|
+
offset: { type: 'number', description: 'Line number to start reading from (1-based).' },
|
|
31
|
+
limit: { type: 'number', description: 'Maximum number of lines to read.' },
|
|
32
|
+
},
|
|
33
|
+
required: ['file_path'],
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
Write: {
|
|
39
|
+
type: 'function',
|
|
40
|
+
function: {
|
|
41
|
+
name: 'write_file',
|
|
42
|
+
description: 'Write content to a file, creating or overwriting it.',
|
|
43
|
+
parameters: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
file_path: { type: 'string', description: 'Absolute path to the file to write.' },
|
|
47
|
+
content: { type: 'string', description: 'The full content to write to the file.' },
|
|
48
|
+
},
|
|
49
|
+
required: ['file_path', 'content'],
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
Edit: {
|
|
55
|
+
type: 'function',
|
|
56
|
+
function: {
|
|
57
|
+
name: 'edit_file',
|
|
58
|
+
description: 'Perform an exact string replacement in a file.',
|
|
59
|
+
parameters: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
file_path: { type: 'string', description: 'Absolute path to the file to edit.' },
|
|
63
|
+
old_string: { type: 'string', description: 'The exact text to find and replace.' },
|
|
64
|
+
new_string: { type: 'string', description: 'The replacement text.' },
|
|
65
|
+
replace_all: {
|
|
66
|
+
type: 'boolean',
|
|
67
|
+
description: 'Replace all occurrences instead of just the first.',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required: ['file_path', 'old_string', 'new_string'],
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
Glob: {
|
|
76
|
+
type: 'function',
|
|
77
|
+
function: {
|
|
78
|
+
name: 'list_files',
|
|
79
|
+
description: 'Find files matching a glob pattern.',
|
|
80
|
+
parameters: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
pattern: { type: 'string', description: 'Glob pattern to match (e.g. "**/*.ts").' },
|
|
84
|
+
path: { type: 'string', description: 'Directory to search in.' },
|
|
85
|
+
},
|
|
86
|
+
required: ['pattern'],
|
|
87
|
+
additionalProperties: false,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
Grep: {
|
|
92
|
+
type: 'function',
|
|
93
|
+
function: {
|
|
94
|
+
name: 'search_content',
|
|
95
|
+
description: 'Search file contents using a regular expression pattern.',
|
|
96
|
+
parameters: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
pattern: { type: 'string', description: 'Regex pattern to search for.' },
|
|
100
|
+
path: { type: 'string', description: 'File or directory to search in.' },
|
|
101
|
+
glob: { type: 'string', description: 'Glob to filter files (e.g. "*.ts").' },
|
|
102
|
+
case_insensitive: { type: 'boolean', description: 'Case-insensitive search.' },
|
|
103
|
+
},
|
|
104
|
+
required: ['pattern'],
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
Bash: {
|
|
110
|
+
type: 'function',
|
|
111
|
+
function: {
|
|
112
|
+
name: 'bash',
|
|
113
|
+
description: 'Execute a shell command and return its output.',
|
|
114
|
+
parameters: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
command: { type: 'string', description: 'The shell command to execute.' },
|
|
118
|
+
timeout: {
|
|
119
|
+
type: 'number',
|
|
120
|
+
description: 'Timeout in milliseconds (max 600000).',
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
required: ['command'],
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
WebSearch: {
|
|
129
|
+
type: 'function',
|
|
130
|
+
function: {
|
|
131
|
+
name: 'web_search',
|
|
132
|
+
description: 'Search the web and return results.',
|
|
133
|
+
parameters: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
query: { type: 'string', description: 'The search query.' },
|
|
137
|
+
},
|
|
138
|
+
required: ['query'],
|
|
139
|
+
additionalProperties: false,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
WebFetch: {
|
|
144
|
+
type: 'function',
|
|
145
|
+
function: {
|
|
146
|
+
name: 'web_fetch',
|
|
147
|
+
description: 'Fetch the content of a web page by URL.',
|
|
148
|
+
parameters: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
url: { type: 'string', description: 'The URL to fetch.' },
|
|
152
|
+
},
|
|
153
|
+
required: ['url'],
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
160
|
+
/**
|
|
161
|
+
* Build OpenAI function-calling tool definitions for the given enabled tools.
|
|
162
|
+
*
|
|
163
|
+
* Only tools that have a known schema are included; unknown names are silently
|
|
164
|
+
* skipped so callers don't need to pre-filter.
|
|
165
|
+
*/
|
|
166
|
+
export function buildToolSchemas(enabledTools) {
|
|
167
|
+
const schemas = [];
|
|
168
|
+
for (const tool of enabledTools) {
|
|
169
|
+
const def = TOOL_DEFS[tool];
|
|
170
|
+
if (def)
|
|
171
|
+
schemas.push(def);
|
|
172
|
+
}
|
|
173
|
+
return schemas;
|
|
174
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildToolSchemas, OPENAI_TO_DISCO_NAME } from './openai-tool-schemas.js';
|
|
3
|
+
const ALL_DISCO_TOOLS = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
|
4
|
+
describe('buildToolSchemas', () => {
|
|
5
|
+
it('returns schemas for all 8 tools when all are enabled', () => {
|
|
6
|
+
const schemas = buildToolSchemas(ALL_DISCO_TOOLS);
|
|
7
|
+
expect(schemas).toHaveLength(8);
|
|
8
|
+
});
|
|
9
|
+
it('each schema has correct shape', () => {
|
|
10
|
+
const schemas = buildToolSchemas(ALL_DISCO_TOOLS);
|
|
11
|
+
for (const schema of schemas) {
|
|
12
|
+
expect(schema.type).toBe('function');
|
|
13
|
+
expect(schema.function).toBeDefined();
|
|
14
|
+
expect(typeof schema.function.name).toBe('string');
|
|
15
|
+
expect(typeof schema.function.description).toBe('string');
|
|
16
|
+
expect(schema.function.parameters).toBeDefined();
|
|
17
|
+
expect(schema.function.parameters).toHaveProperty('type', 'object');
|
|
18
|
+
expect(schema.function.parameters).toHaveProperty('properties');
|
|
19
|
+
expect(schema.function.parameters).toHaveProperty('required');
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
it('returns correct OpenAI function names', () => {
|
|
23
|
+
const schemas = buildToolSchemas(ALL_DISCO_TOOLS);
|
|
24
|
+
const names = schemas.map((s) => s.function.name);
|
|
25
|
+
expect(names).toEqual([
|
|
26
|
+
'read_file',
|
|
27
|
+
'write_file',
|
|
28
|
+
'edit_file',
|
|
29
|
+
'list_files',
|
|
30
|
+
'search_content',
|
|
31
|
+
'bash',
|
|
32
|
+
'web_search',
|
|
33
|
+
'web_fetch',
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
it('returns subset when only some tools are enabled', () => {
|
|
37
|
+
const schemas = buildToolSchemas(['Read', 'Bash']);
|
|
38
|
+
expect(schemas).toHaveLength(2);
|
|
39
|
+
expect(schemas[0].function.name).toBe('read_file');
|
|
40
|
+
expect(schemas[1].function.name).toBe('bash');
|
|
41
|
+
});
|
|
42
|
+
it('returns empty array for empty input', () => {
|
|
43
|
+
expect(buildToolSchemas([])).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
it('silently skips unknown tool names', () => {
|
|
46
|
+
const schemas = buildToolSchemas(['Read', 'UnknownTool', 'Bash']);
|
|
47
|
+
expect(schemas).toHaveLength(2);
|
|
48
|
+
expect(schemas.map((s) => s.function.name)).toEqual(['read_file', 'bash']);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('OPENAI_TO_DISCO_NAME', () => {
|
|
52
|
+
it('maps every OpenAI function name back to the disco tool name', () => {
|
|
53
|
+
expect(OPENAI_TO_DISCO_NAME['read_file']).toBe('Read');
|
|
54
|
+
expect(OPENAI_TO_DISCO_NAME['write_file']).toBe('Write');
|
|
55
|
+
expect(OPENAI_TO_DISCO_NAME['edit_file']).toBe('Edit');
|
|
56
|
+
expect(OPENAI_TO_DISCO_NAME['list_files']).toBe('Glob');
|
|
57
|
+
expect(OPENAI_TO_DISCO_NAME['search_content']).toBe('Grep');
|
|
58
|
+
expect(OPENAI_TO_DISCO_NAME['bash']).toBe('Bash');
|
|
59
|
+
expect(OPENAI_TO_DISCO_NAME['web_search']).toBe('WebSearch');
|
|
60
|
+
expect(OPENAI_TO_DISCO_NAME['web_fetch']).toBe('WebFetch');
|
|
61
|
+
});
|
|
62
|
+
it('is consistent with schemas — every schema name has a reverse mapping', () => {
|
|
63
|
+
const schemas = buildToolSchemas(ALL_DISCO_TOOLS);
|
|
64
|
+
for (const schema of schemas) {
|
|
65
|
+
const openaiName = schema.function.name;
|
|
66
|
+
expect(OPENAI_TO_DISCO_NAME[openaiName]).toBeDefined();
|
|
67
|
+
// And the reverse mapping should be one of our disco tool names
|
|
68
|
+
expect(ALL_DISCO_TOOLS).toContain(OPENAI_TO_DISCO_NAME[openaiName]);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
it('has exactly 8 entries', () => {
|
|
72
|
+
expect(Object.keys(OPENAI_TO_DISCO_NAME)).toHaveLength(8);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI function-calling tool: list_files
|
|
3
|
+
*
|
|
4
|
+
* Finds files matching a glob pattern within allowed directories.
|
|
5
|
+
* Uses Node 22+ fs.glob when available, falling back to recursive
|
|
6
|
+
* readdir with simple glob matching.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { resolveAndCheck } from './path-security.js';
|
|
11
|
+
export const name = 'list_files';
|
|
12
|
+
const MAX_RESULTS = 1000;
|
|
13
|
+
const MAX_SCAN_FILES = 5000;
|
|
14
|
+
export const schema = {
|
|
15
|
+
type: 'function',
|
|
16
|
+
function: {
|
|
17
|
+
name: 'list_files',
|
|
18
|
+
description: 'Find files matching a glob pattern.',
|
|
19
|
+
parameters: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
pattern: { type: 'string', description: 'Glob pattern to match (e.g. "**/*.ts").' },
|
|
23
|
+
path: { type: 'string', description: 'Directory to search in.' },
|
|
24
|
+
},
|
|
25
|
+
required: ['pattern'],
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
export async function execute(args, allowedRoots) {
|
|
31
|
+
const pattern = args.pattern;
|
|
32
|
+
const searchPath = args.path;
|
|
33
|
+
if (!pattern)
|
|
34
|
+
return { result: 'pattern is required', ok: false };
|
|
35
|
+
try {
|
|
36
|
+
const baseDir = searchPath
|
|
37
|
+
? await resolveAndCheck(searchPath, allowedRoots)
|
|
38
|
+
: allowedRoots[0];
|
|
39
|
+
const matches = [];
|
|
40
|
+
if (typeof fs.glob === 'function') {
|
|
41
|
+
// Node 22+ fs.glob
|
|
42
|
+
const globFn = fs.glob;
|
|
43
|
+
for await (const entry of globFn(pattern, { cwd: baseDir })) {
|
|
44
|
+
matches.push(entry);
|
|
45
|
+
if (matches.length >= MAX_RESULTS)
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Fallback: recursive readdir + simple glob matching
|
|
51
|
+
const allFiles = await collectFiles(baseDir, baseDir, MAX_SCAN_FILES);
|
|
52
|
+
for (const file of allFiles) {
|
|
53
|
+
if (simpleGlobMatch(file, pattern)) {
|
|
54
|
+
matches.push(file);
|
|
55
|
+
if (matches.length >= MAX_RESULTS)
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (matches.length === 0) {
|
|
61
|
+
return { result: 'No files matched', ok: true };
|
|
62
|
+
}
|
|
63
|
+
return { result: matches.join('\n'), ok: true };
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
67
|
+
return { result: message, ok: false };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Recursively collect relative file paths. */
|
|
71
|
+
async function collectFiles(dir, base, limit) {
|
|
72
|
+
const results = [];
|
|
73
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (results.length >= limit)
|
|
76
|
+
break;
|
|
77
|
+
const full = path.join(dir, entry.name);
|
|
78
|
+
const rel = path.relative(base, full);
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
// Skip hidden dirs and node_modules
|
|
81
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
82
|
+
continue;
|
|
83
|
+
const sub = await collectFiles(full, base, limit - results.length);
|
|
84
|
+
results.push(...sub);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
results.push(rel);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
/** Basic glob matching: ** → any path, * → any non-sep, ? → single char. */
|
|
93
|
+
export function simpleGlobMatch(file, pattern) {
|
|
94
|
+
let re = pattern
|
|
95
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex specials (except * and ?)
|
|
96
|
+
.replace(/\*\*/g, '\0DOUBLESTAR\0')
|
|
97
|
+
.replace(/\*/g, '[^/]*')
|
|
98
|
+
.replace(/\0DOUBLESTAR\0/g, '.*')
|
|
99
|
+
.replace(/\?/g, '[^/]');
|
|
100
|
+
re = '^' + re + '$';
|
|
101
|
+
return new RegExp(re).test(file);
|
|
102
|
+
}
|