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,67 @@
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 } from 'vitest';
5
+ import { execute, name, schema, simpleGlobMatch } from './fs-glob.js';
6
+ let tmpDir;
7
+ beforeEach(async () => {
8
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-glob-'));
9
+ });
10
+ afterEach(async () => {
11
+ await fs.rm(tmpDir, { recursive: true, force: true });
12
+ });
13
+ describe('fs-glob schema', () => {
14
+ it('has correct name and shape', () => {
15
+ expect(name).toBe('list_files');
16
+ expect(schema.type).toBe('function');
17
+ expect(schema.function.name).toBe('list_files');
18
+ expect(schema.function.parameters).toHaveProperty('required', ['pattern']);
19
+ });
20
+ });
21
+ describe('simpleGlobMatch', () => {
22
+ it('matches *.ts against .ts files', () => {
23
+ expect(simpleGlobMatch('foo.ts', '*.ts')).toBe(true);
24
+ expect(simpleGlobMatch('foo.js', '*.ts')).toBe(false);
25
+ });
26
+ it('matches **/*.ts recursively', () => {
27
+ expect(simpleGlobMatch('src/foo.ts', '**/*.ts')).toBe(true);
28
+ expect(simpleGlobMatch('src/deep/bar.ts', '**/*.ts')).toBe(true);
29
+ expect(simpleGlobMatch('foo.js', '**/*.ts')).toBe(false);
30
+ });
31
+ it('matches ** alone for any path', () => {
32
+ expect(simpleGlobMatch('foo.ts', '**')).toBe(true);
33
+ expect(simpleGlobMatch('src/foo.ts', '**')).toBe(true);
34
+ });
35
+ it('matches ? as a single character', () => {
36
+ expect(simpleGlobMatch('a.ts', '?.ts')).toBe(true);
37
+ expect(simpleGlobMatch('ab.ts', '?.ts')).toBe(false);
38
+ });
39
+ });
40
+ describe('fs-glob execute', () => {
41
+ it('finds files matching a glob pattern', async () => {
42
+ await fs.writeFile(path.join(tmpDir, 'a.ts'), '');
43
+ await fs.writeFile(path.join(tmpDir, 'b.ts'), '');
44
+ await fs.writeFile(path.join(tmpDir, 'c.js'), '');
45
+ const r = await execute({ pattern: '*.ts', path: tmpDir }, [tmpDir]);
46
+ expect(r.ok).toBe(true);
47
+ expect(r.result).toContain('a.ts');
48
+ expect(r.result).toContain('b.ts');
49
+ expect(r.result).not.toContain('c.js');
50
+ });
51
+ it('returns message when no files match', async () => {
52
+ const r = await execute({ pattern: '*.xyz', path: tmpDir }, [tmpDir]);
53
+ expect(r.ok).toBe(true);
54
+ expect(r.result).toContain('No files matched');
55
+ });
56
+ it('returns error when pattern is missing', async () => {
57
+ const r = await execute({}, [tmpDir]);
58
+ expect(r.ok).toBe(false);
59
+ expect(r.result).toContain('pattern');
60
+ });
61
+ it('uses first allowed root as default search path', async () => {
62
+ await fs.writeFile(path.join(tmpDir, 'file.txt'), '');
63
+ const r = await execute({ pattern: '*.txt' }, [tmpDir]);
64
+ expect(r.ok).toBe(true);
65
+ expect(r.result).toContain('file.txt');
66
+ });
67
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * OpenAI function-calling tool: read_file
3
+ *
4
+ * Reads the contents of a file at a given path, with optional line
5
+ * offset and limit for partial reads. Enforces a 1 MB size cap.
6
+ */
7
+ import fs from 'node:fs/promises';
8
+ import { resolveAndCheck } from './path-security.js';
9
+ export const name = 'read_file';
10
+ const MAX_READ_BYTES = 1 * 1024 * 1024; // 1 MB
11
+ export const schema = {
12
+ type: 'function',
13
+ function: {
14
+ name: 'read_file',
15
+ description: 'Read the contents of a file at the given path.',
16
+ parameters: {
17
+ type: 'object',
18
+ properties: {
19
+ file_path: { type: 'string', description: 'Absolute path to the file to read.' },
20
+ offset: { type: 'number', description: 'Line number to start reading from (1-based).' },
21
+ limit: { type: 'number', description: 'Maximum number of lines to read.' },
22
+ },
23
+ required: ['file_path'],
24
+ additionalProperties: false,
25
+ },
26
+ },
27
+ };
28
+ export async function execute(args, allowedRoots) {
29
+ const filePath = args.file_path;
30
+ if (!filePath)
31
+ return { result: 'file_path is required', ok: false };
32
+ try {
33
+ const resolved = await resolveAndCheck(filePath, allowedRoots);
34
+ const stat = await fs.stat(resolved);
35
+ if (stat.size > MAX_READ_BYTES) {
36
+ return { result: `File too large: ${stat.size} bytes (max ${MAX_READ_BYTES})`, ok: false };
37
+ }
38
+ const content = await fs.readFile(resolved, 'utf-8');
39
+ const lines = content.split('\n');
40
+ const offset = typeof args.offset === 'number' ? Math.max(0, args.offset - 1) : 0; // 1-based to 0-based
41
+ const limit = typeof args.limit === 'number' ? args.limit : lines.length;
42
+ const sliced = lines.slice(offset, offset + limit);
43
+ return { result: sliced.join('\n'), ok: true };
44
+ }
45
+ catch (err) {
46
+ const message = err instanceof Error ? err.message : String(err);
47
+ return { result: message, ok: false };
48
+ }
49
+ }
@@ -0,0 +1,51 @@
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 } from 'vitest';
5
+ import { execute, name, schema } from './fs-read-file.js';
6
+ let tmpDir;
7
+ beforeEach(async () => {
8
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-read-'));
9
+ });
10
+ afterEach(async () => {
11
+ await fs.rm(tmpDir, { recursive: true, force: true });
12
+ });
13
+ describe('fs-read-file schema', () => {
14
+ it('has correct name and shape', () => {
15
+ expect(name).toBe('read_file');
16
+ expect(schema.type).toBe('function');
17
+ expect(schema.function.name).toBe('read_file');
18
+ expect(schema.function.parameters).toHaveProperty('required', ['file_path']);
19
+ });
20
+ });
21
+ describe('fs-read-file execute', () => {
22
+ it('reads an existing file', async () => {
23
+ const filePath = path.join(tmpDir, 'hello.txt');
24
+ await fs.writeFile(filePath, 'hello world\n');
25
+ const r = await execute({ file_path: filePath }, [tmpDir]);
26
+ expect(r.ok).toBe(true);
27
+ expect(r.result).toBe('hello world\n');
28
+ });
29
+ it('returns error for nonexistent file', async () => {
30
+ const r = await execute({ file_path: path.join(tmpDir, 'nope.txt') }, [tmpDir]);
31
+ expect(r.ok).toBe(false);
32
+ expect(r.result).toMatch(/ENOENT|no such file|not accessible/i);
33
+ });
34
+ it('reads with offset and limit', async () => {
35
+ const filePath = path.join(tmpDir, 'lines.txt');
36
+ await fs.writeFile(filePath, 'line1\nline2\nline3\nline4\nline5\n');
37
+ const r = await execute({ file_path: filePath, offset: 2, limit: 2 }, [tmpDir]);
38
+ expect(r.ok).toBe(true);
39
+ expect(r.result).toBe('line2\nline3');
40
+ });
41
+ it('returns error when file_path is missing', async () => {
42
+ const r = await execute({}, [tmpDir]);
43
+ expect(r.ok).toBe(false);
44
+ expect(r.result).toContain('file_path');
45
+ });
46
+ it('rejects path outside allowed roots', async () => {
47
+ const r = await execute({ file_path: '/etc/hostname' }, [tmpDir]);
48
+ expect(r.ok).toBe(false);
49
+ expect(r.result).toMatch(/outside allowed roots|not accessible/i);
50
+ });
51
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * OpenAI function-calling tool: realpath
3
+ *
4
+ * Resolves a file or directory path to its canonical absolute path,
5
+ * following all symlinks. Returns an error if the path does not exist
6
+ * or falls outside the allowed roots.
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { canonicalizeRoots } from './path-security.js';
11
+ export const name = 'realpath';
12
+ export const schema = {
13
+ type: 'function',
14
+ function: {
15
+ name: 'realpath',
16
+ description: 'Resolve the canonical absolute path of a file or directory, following symlinks.',
17
+ parameters: {
18
+ type: 'object',
19
+ properties: {
20
+ file_path: { type: 'string', description: 'Path to resolve (absolute or relative to workspace).' },
21
+ },
22
+ required: ['file_path'],
23
+ additionalProperties: false,
24
+ },
25
+ },
26
+ };
27
+ export async function execute(args, allowedRoots) {
28
+ const filePath = args.file_path;
29
+ if (!filePath)
30
+ return { result: 'file_path is required', ok: false };
31
+ // Resolve relative paths against the first allowed root
32
+ const resolved = path.resolve(allowedRoots[0], filePath);
33
+ let canonical;
34
+ try {
35
+ canonical = await fs.realpath(resolved);
36
+ }
37
+ catch (err) {
38
+ const code = err.code;
39
+ if (code === 'ENOENT') {
40
+ return { result: `Path does not exist: ${resolved}`, ok: false };
41
+ }
42
+ return { result: `Cannot resolve path: ${resolved}`, ok: false };
43
+ }
44
+ // Verify the canonical path falls within allowed roots
45
+ const canonicalRoots = await canonicalizeRoots(allowedRoots);
46
+ const allowed = canonicalRoots.some((root) => canonical === root || canonical.startsWith(root + path.sep));
47
+ if (!allowed) {
48
+ return { result: `Path outside allowed roots: ${filePath}`, ok: false };
49
+ }
50
+ return { result: canonical, ok: true };
51
+ }
@@ -0,0 +1,72 @@
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 } from 'vitest';
5
+ import { execute, name, schema } from './fs-realpath.js';
6
+ let tmpDir;
7
+ beforeEach(async () => {
8
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-realpath-'));
9
+ });
10
+ afterEach(async () => {
11
+ await fs.rm(tmpDir, { recursive: true, force: true });
12
+ });
13
+ describe('fs-realpath schema', () => {
14
+ it('has correct name and shape', () => {
15
+ expect(name).toBe('realpath');
16
+ expect(schema.type).toBe('function');
17
+ expect(schema.function.name).toBe('realpath');
18
+ expect(schema.function.parameters).toHaveProperty('required', ['file_path']);
19
+ });
20
+ });
21
+ describe('fs-realpath execute', () => {
22
+ it('resolves an existing file to its canonical path', async () => {
23
+ const filePath = path.join(tmpDir, 'hello.txt');
24
+ await fs.writeFile(filePath, 'hello');
25
+ const r = await execute({ file_path: filePath }, [tmpDir]);
26
+ expect(r.ok).toBe(true);
27
+ // The result should be the canonical path
28
+ const expected = await fs.realpath(filePath);
29
+ expect(r.result).toBe(expected);
30
+ });
31
+ it('resolves a symlink to its target', async () => {
32
+ const target = path.join(tmpDir, 'target.txt');
33
+ await fs.writeFile(target, 'content');
34
+ const link = path.join(tmpDir, 'link.txt');
35
+ await fs.symlink(target, link);
36
+ const r = await execute({ file_path: link }, [tmpDir]);
37
+ expect(r.ok).toBe(true);
38
+ expect(r.result).toBe(await fs.realpath(target));
39
+ });
40
+ it('resolves a relative path against the first allowed root', async () => {
41
+ await fs.writeFile(path.join(tmpDir, 'rel.txt'), 'data');
42
+ const r = await execute({ file_path: 'rel.txt' }, [tmpDir]);
43
+ expect(r.ok).toBe(true);
44
+ expect(r.result).toBe(await fs.realpath(path.join(tmpDir, 'rel.txt')));
45
+ });
46
+ it('returns error for nonexistent path', async () => {
47
+ const r = await execute({ file_path: path.join(tmpDir, 'nope.txt') }, [tmpDir]);
48
+ expect(r.ok).toBe(false);
49
+ expect(r.result).toContain('does not exist');
50
+ });
51
+ it('returns error when file_path is missing', async () => {
52
+ const r = await execute({}, [tmpDir]);
53
+ expect(r.ok).toBe(false);
54
+ expect(r.result).toContain('file_path');
55
+ });
56
+ it('rejects symlink pointing outside allowed roots', async () => {
57
+ const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-outside-'));
58
+ const outsideFile = path.join(outsideDir, 'secret.txt');
59
+ await fs.writeFile(outsideFile, 'secret');
60
+ const link = path.join(tmpDir, 'escape');
61
+ await fs.symlink(outsideFile, link);
62
+ const r = await execute({ file_path: link }, [tmpDir]);
63
+ expect(r.ok).toBe(false);
64
+ expect(r.result).toContain('outside allowed roots');
65
+ await fs.rm(outsideDir, { recursive: true, force: true });
66
+ });
67
+ it('rejects absolute path outside allowed roots', async () => {
68
+ const r = await execute({ file_path: '/etc/hostname' }, [tmpDir]);
69
+ expect(r.ok).toBe(false);
70
+ expect(r.result).toMatch(/outside allowed roots|does not exist/i);
71
+ });
72
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * OpenAI function-calling tool: write_file
3
+ *
4
+ * Writes content to a file, creating parent directories as needed.
5
+ * Overwrites the file if it already exists.
6
+ */
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { resolveAndCheck } from './path-security.js';
10
+ export const name = 'write_file';
11
+ export const schema = {
12
+ type: 'function',
13
+ function: {
14
+ name: 'write_file',
15
+ description: 'Write content to a file, creating or overwriting it.',
16
+ parameters: {
17
+ type: 'object',
18
+ properties: {
19
+ file_path: { type: 'string', description: 'Absolute path to the file to write.' },
20
+ content: { type: 'string', description: 'The full content to write to the file.' },
21
+ },
22
+ required: ['file_path', 'content'],
23
+ additionalProperties: false,
24
+ },
25
+ },
26
+ };
27
+ export async function execute(args, allowedRoots) {
28
+ const filePath = args.file_path;
29
+ const content = args.content;
30
+ if (!filePath)
31
+ return { result: 'file_path is required', ok: false };
32
+ if (typeof content !== 'string')
33
+ return { result: 'content is required', ok: false };
34
+ try {
35
+ const resolved = await resolveAndCheck(filePath, allowedRoots, true);
36
+ // Ensure parent directory exists
37
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
38
+ await fs.writeFile(resolved, content, 'utf-8');
39
+ return { result: `Wrote ${Buffer.byteLength(content)} bytes to ${resolved}`, ok: true };
40
+ }
41
+ catch (err) {
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ return { result: message, ok: false };
44
+ }
45
+ }
@@ -0,0 +1,56 @@
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 } from 'vitest';
5
+ import { execute, name, schema } from './fs-write-file.js';
6
+ let tmpDir;
7
+ beforeEach(async () => {
8
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-write-'));
9
+ });
10
+ afterEach(async () => {
11
+ await fs.rm(tmpDir, { recursive: true, force: true });
12
+ });
13
+ describe('fs-write-file schema', () => {
14
+ it('has correct name and shape', () => {
15
+ expect(name).toBe('write_file');
16
+ expect(schema.type).toBe('function');
17
+ expect(schema.function.name).toBe('write_file');
18
+ expect(schema.function.parameters).toHaveProperty('required', ['file_path', 'content']);
19
+ });
20
+ });
21
+ describe('fs-write-file execute', () => {
22
+ it('writes a new file and creates parent directories', async () => {
23
+ const filePath = path.join(tmpDir, 'sub', 'dir', 'output.txt');
24
+ const r = await execute({ file_path: filePath, content: 'created!' }, [tmpDir]);
25
+ expect(r.ok).toBe(true);
26
+ const contents = await fs.readFile(filePath, 'utf-8');
27
+ expect(contents).toBe('created!');
28
+ });
29
+ it('overwrites an existing file', async () => {
30
+ const filePath = path.join(tmpDir, 'existing.txt');
31
+ await fs.writeFile(filePath, 'old content');
32
+ const r = await execute({ file_path: filePath, content: 'new content' }, [tmpDir]);
33
+ expect(r.ok).toBe(true);
34
+ const contents = await fs.readFile(filePath, 'utf-8');
35
+ expect(contents).toBe('new content');
36
+ });
37
+ it('returns error when content is missing', async () => {
38
+ const r = await execute({ file_path: path.join(tmpDir, 'x.txt') }, [tmpDir]);
39
+ expect(r.ok).toBe(false);
40
+ expect(r.result).toContain('content');
41
+ });
42
+ it('returns error when file_path is missing', async () => {
43
+ const r = await execute({ content: 'data' }, [tmpDir]);
44
+ expect(r.ok).toBe(false);
45
+ expect(r.result).toContain('file_path');
46
+ });
47
+ it('rejects write outside allowed roots', async () => {
48
+ const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-outside-'));
49
+ const filePath = path.join(outsideDir, 'injected.txt');
50
+ const r = await execute({ file_path: filePath, content: 'injected' }, [tmpDir]);
51
+ expect(r.ok).toBe(false);
52
+ expect(r.result).toMatch(/outside allowed roots|not accessible/i);
53
+ await expect(fs.access(filePath)).rejects.toThrow();
54
+ await fs.rm(outsideDir, { recursive: true, force: true });
55
+ });
56
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * OpenAI function-calling tool: download_image
3
+ *
4
+ * Downloads an image from a URL and returns it as base64-encoded data.
5
+ * Includes SSRF protections, size limits, and magic-byte validation.
6
+ */
7
+ export const name = 'download_image';
8
+ const FETCH_TIMEOUT_MS = 15_000;
9
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
10
+ /** RFC 1918 / private / loopback prefixes for SSRF protection. */
11
+ const PRIVATE_IP_PREFIXES = [
12
+ '10.',
13
+ '172.16.', '172.17.', '172.18.', '172.19.',
14
+ '172.20.', '172.21.', '172.22.', '172.23.',
15
+ '172.24.', '172.25.', '172.26.', '172.27.',
16
+ '172.28.', '172.29.', '172.30.', '172.31.',
17
+ '192.168.',
18
+ '127.',
19
+ '0.',
20
+ '169.254.',
21
+ ];
22
+ const LOCALHOST_HOSTNAMES = new Set(['localhost', '[::1]']);
23
+ const SUPPORTED_MEDIA_TYPES = new Set([
24
+ 'image/png',
25
+ 'image/jpeg',
26
+ 'image/webp',
27
+ 'image/gif',
28
+ ]);
29
+ export const schema = {
30
+ type: 'function',
31
+ function: {
32
+ name: 'download_image',
33
+ description: 'Download an image from a URL and return it as base64-encoded data with its media type.',
34
+ parameters: {
35
+ type: 'object',
36
+ properties: {
37
+ url: { type: 'string', description: 'The HTTPS URL of the image to download.' },
38
+ },
39
+ required: ['url'],
40
+ additionalProperties: false,
41
+ },
42
+ },
43
+ };
44
+ /**
45
+ * Sniff the image format from magic bytes.
46
+ * Returns the MIME type string or null if unrecognized.
47
+ */
48
+ export function sniffMediaType(buffer) {
49
+ // PNG: 8-byte signature 89 50 4E 47 0D 0A 1A 0A
50
+ if (buffer.length >= 8 &&
51
+ buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47 &&
52
+ buffer[4] === 0x0D && buffer[5] === 0x0A && buffer[6] === 0x1A && buffer[7] === 0x0A) {
53
+ return 'image/png';
54
+ }
55
+ // JPEG: 3-byte SOI + marker FF D8 FF
56
+ if (buffer.length >= 3 &&
57
+ buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
58
+ return 'image/jpeg';
59
+ }
60
+ // GIF: 6-byte version string GIF87a or GIF89a
61
+ if (buffer.length >= 6 &&
62
+ buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38 &&
63
+ (buffer[4] === 0x37 || buffer[4] === 0x39) && buffer[5] === 0x61) {
64
+ return 'image/gif';
65
+ }
66
+ // WebP: RIFF....WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP)
67
+ if (buffer.length >= 12 &&
68
+ buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
69
+ buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
70
+ return 'image/webp';
71
+ }
72
+ return null;
73
+ }
74
+ export async function execute(args, _allowedRoots) {
75
+ const url = args.url;
76
+ if (!url)
77
+ return { result: 'url is required', ok: false };
78
+ // Validate URL
79
+ let parsed;
80
+ try {
81
+ parsed = new URL(url);
82
+ }
83
+ catch {
84
+ return { result: 'Invalid URL', ok: false };
85
+ }
86
+ // HTTPS only
87
+ if (parsed.protocol !== 'https:') {
88
+ return { result: `Blocked: only HTTPS URLs are allowed (got ${parsed.protocol})`, ok: false };
89
+ }
90
+ // Block private/loopback IPs and localhost
91
+ const hostname = parsed.hostname;
92
+ if (LOCALHOST_HOSTNAMES.has(hostname)) {
93
+ return { result: 'Blocked: localhost URLs are not allowed', ok: false };
94
+ }
95
+ if (PRIVATE_IP_PREFIXES.some((prefix) => hostname.startsWith(prefix))) {
96
+ return { result: 'Blocked: private/internal IP addresses are not allowed', ok: false };
97
+ }
98
+ if (hostname === '::1' || hostname === '[::1]') {
99
+ return { result: 'Blocked: loopback addresses are not allowed', ok: false };
100
+ }
101
+ try {
102
+ const response = await fetch(url, {
103
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
104
+ redirect: 'error',
105
+ });
106
+ if (!response.ok) {
107
+ return { result: `HTTP ${response.status} ${response.statusText}`, ok: false };
108
+ }
109
+ const buffer = Buffer.from(await response.arrayBuffer());
110
+ if (buffer.length > MAX_IMAGE_BYTES) {
111
+ const sizeMB = (buffer.length / (1024 * 1024)).toFixed(1);
112
+ return { result: `Image too large: ${sizeMB} MB (max 20 MB)`, ok: false };
113
+ }
114
+ // Validate image format via magic bytes
115
+ const mediaType = sniffMediaType(buffer);
116
+ if (!mediaType) {
117
+ return { result: 'Not a recognized image format (expected PNG, JPEG, GIF, or WebP)', ok: false };
118
+ }
119
+ if (!SUPPORTED_MEDIA_TYPES.has(mediaType)) {
120
+ return { result: `Unsupported image format: ${mediaType}`, ok: false };
121
+ }
122
+ const base64 = buffer.toString('base64');
123
+ return {
124
+ result: JSON.stringify({ base64, media_type: mediaType, size: buffer.length }),
125
+ ok: true,
126
+ };
127
+ }
128
+ catch (err) {
129
+ const e = err instanceof Error ? err : null;
130
+ if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
131
+ return { result: 'Request timed out (15s limit)', ok: false };
132
+ }
133
+ if (e?.name === 'TypeError' && String(e.message).includes('redirect')) {
134
+ return { result: 'Blocked: unexpected redirect', ok: false };
135
+ }
136
+ return { result: e?.message || 'download failed', ok: false };
137
+ }
138
+ }
@@ -0,0 +1,106 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { execute, name, schema, sniffMediaType } from './image-download.js';
3
+ // Minimal valid PNG: 8-byte signature
4
+ const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
5
+ // Minimal valid JPEG: SOI + marker
6
+ const JPEG_HEADER = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]);
7
+ // Minimal valid GIF: GIF89a
8
+ const GIF_HEADER = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
9
+ // Minimal valid WebP: RIFF....WEBP
10
+ const WEBP_HEADER = Buffer.from([
11
+ 0x52, 0x49, 0x46, 0x46, // RIFF
12
+ 0x00, 0x00, 0x00, 0x00, // file size (placeholder)
13
+ 0x57, 0x45, 0x42, 0x50, // WEBP
14
+ ]);
15
+ describe('image-download schema', () => {
16
+ it('has correct name and shape', () => {
17
+ expect(name).toBe('download_image');
18
+ expect(schema.type).toBe('function');
19
+ expect(schema.function.name).toBe('download_image');
20
+ expect(schema.function.parameters).toHaveProperty('required', ['url']);
21
+ });
22
+ });
23
+ describe('sniffMediaType', () => {
24
+ it('detects PNG', () => {
25
+ expect(sniffMediaType(PNG_HEADER)).toBe('image/png');
26
+ });
27
+ it('detects JPEG', () => {
28
+ expect(sniffMediaType(JPEG_HEADER)).toBe('image/jpeg');
29
+ });
30
+ it('detects GIF', () => {
31
+ expect(sniffMediaType(GIF_HEADER)).toBe('image/gif');
32
+ });
33
+ it('detects WebP', () => {
34
+ expect(sniffMediaType(WEBP_HEADER)).toBe('image/webp');
35
+ });
36
+ it('returns null for unknown formats', () => {
37
+ expect(sniffMediaType(Buffer.from([0x00, 0x00, 0x00, 0x00]))).toBeNull();
38
+ });
39
+ it('returns null for empty buffer', () => {
40
+ expect(sniffMediaType(Buffer.alloc(0))).toBeNull();
41
+ });
42
+ });
43
+ describe('image-download execute', () => {
44
+ const originalFetch = globalThis.fetch;
45
+ afterEach(() => {
46
+ globalThis.fetch = originalFetch;
47
+ });
48
+ it('downloads and returns a PNG image as base64', async () => {
49
+ // Create a minimal valid PNG (header + some padding)
50
+ const pngData = Buffer.concat([PNG_HEADER, Buffer.alloc(100)]);
51
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response(pngData, { status: 200 }));
52
+ const r = await execute({ url: 'https://example.com/image.png' }, ['/tmp']);
53
+ expect(r.ok).toBe(true);
54
+ const parsed = JSON.parse(r.result);
55
+ expect(parsed.media_type).toBe('image/png');
56
+ expect(parsed.base64).toBe(pngData.toString('base64'));
57
+ expect(parsed.size).toBe(pngData.length);
58
+ });
59
+ it('returns error when url is missing', async () => {
60
+ const r = await execute({}, ['/tmp']);
61
+ expect(r.ok).toBe(false);
62
+ expect(r.result).toContain('url');
63
+ });
64
+ it('rejects HTTP (non-HTTPS) URLs', async () => {
65
+ const r = await execute({ url: 'http://example.com/image.png' }, ['/tmp']);
66
+ expect(r.ok).toBe(false);
67
+ expect(r.result).toContain('HTTPS');
68
+ });
69
+ it('rejects private IP addresses', async () => {
70
+ const r = await execute({ url: 'https://10.0.0.1/image.png' }, ['/tmp']);
71
+ expect(r.ok).toBe(false);
72
+ expect(r.result).toContain('private');
73
+ });
74
+ it('rejects localhost', async () => {
75
+ const r = await execute({ url: 'https://localhost/image.png' }, ['/tmp']);
76
+ expect(r.ok).toBe(false);
77
+ expect(r.result).toContain('localhost');
78
+ });
79
+ it('rejects 192.168.x addresses', async () => {
80
+ const r = await execute({ url: 'https://192.168.1.1/image.png' }, ['/tmp']);
81
+ expect(r.ok).toBe(false);
82
+ expect(r.result).toContain('private');
83
+ });
84
+ it('rejects loopback IP (127.x)', async () => {
85
+ const r = await execute({ url: 'https://127.0.0.1/image.png' }, ['/tmp']);
86
+ expect(r.ok).toBe(false);
87
+ expect(r.result).toContain('private');
88
+ });
89
+ it('rejects invalid URLs', async () => {
90
+ const r = await execute({ url: 'not-a-url' }, ['/tmp']);
91
+ expect(r.ok).toBe(false);
92
+ expect(r.result).toContain('Invalid URL');
93
+ });
94
+ it('returns error for non-image content', async () => {
95
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response('plain text', { status: 200 }));
96
+ const r = await execute({ url: 'https://example.com/file.txt' }, ['/tmp']);
97
+ expect(r.ok).toBe(false);
98
+ expect(r.result).toContain('Not a recognized image format');
99
+ });
100
+ it('returns error on HTTP error status', async () => {
101
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response('not found', { status: 404, statusText: 'Not Found' }));
102
+ const r = await execute({ url: 'https://example.com/missing.png' }, ['/tmp']);
103
+ expect(r.ok).toBe(false);
104
+ expect(r.result).toContain('404');
105
+ });
106
+ });