@2en/clawly-plugins 1.30.0-beta.2 → 1.30.0-beta.4
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/clawly-config-defaults.json5 +107 -3
- package/config-setup.ts +140 -324
- package/gateway/index.ts +0 -2
- package/gateway/offline-push.test.ts +39 -20
- package/gateway/offline-push.ts +28 -0
- package/index.ts +1 -1
- package/outbound.ts +20 -24
- package/package.json +4 -5
- package/tools/clawly-send-file.test.ts +400 -0
- package/tools/clawly-send-file.ts +307 -0
- package/tools/index.ts +2 -2
- package/types.ts +1 -1
- package/gateway/node-dangerous-allowlist.ts +0 -84
- package/tools/clawly-send-image.ts +0 -228
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import {afterAll, beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
import fsp from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import type {PluginApi} from '../types'
|
|
6
|
+
import {registerSendFileTool} from './clawly-send-file'
|
|
7
|
+
|
|
8
|
+
// ── Mocks ────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
let mockInjectParams: {sessionKey: string; message: string} | null = null
|
|
11
|
+
let mockFileTypeFromBuffer: (buf: Uint8Array) => Promise<{mime: string; ext: string} | undefined>
|
|
12
|
+
let mockFileTypeFromFile: (p: string) => Promise<{mime: string; ext: string} | undefined>
|
|
13
|
+
|
|
14
|
+
mock.module('../gateway/inject', () => ({
|
|
15
|
+
resolveSessionKey: async () => 'agent:clawly:main',
|
|
16
|
+
injectAssistantMessage: async (params: {sessionKey: string; message: string}) => {
|
|
17
|
+
mockInjectParams = params
|
|
18
|
+
return {ok: true, messageId: 'msg-123'}
|
|
19
|
+
},
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
mock.module('file-type', () => ({
|
|
23
|
+
fileTypeFromBuffer: async (buf: Uint8Array) => mockFileTypeFromBuffer(buf),
|
|
24
|
+
fileTypeFromFile: async (p: string) => mockFileTypeFromFile(p),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
type ToolExecute = (
|
|
30
|
+
toolCallId: string,
|
|
31
|
+
params: Record<string, unknown>,
|
|
32
|
+
) => Promise<{content: {type: string; text: string}[]}>
|
|
33
|
+
|
|
34
|
+
function createMockApi(stateDir?: string): {
|
|
35
|
+
api: PluginApi
|
|
36
|
+
logs: {level: string; msg: string}[]
|
|
37
|
+
execute: ToolExecute
|
|
38
|
+
} {
|
|
39
|
+
const logs: {level: string; msg: string}[] = []
|
|
40
|
+
let registeredExecute: ToolExecute | null = null
|
|
41
|
+
const dir = stateDir ?? path.join(os.tmpdir(), `clawly-send-file-test-${Date.now()}`)
|
|
42
|
+
|
|
43
|
+
const api = {
|
|
44
|
+
id: 'test',
|
|
45
|
+
name: 'test',
|
|
46
|
+
logger: {
|
|
47
|
+
info: (msg: string) => logs.push({level: 'info', msg}),
|
|
48
|
+
warn: (msg: string) => logs.push({level: 'warn', msg}),
|
|
49
|
+
error: (msg: string) => logs.push({level: 'error', msg}),
|
|
50
|
+
},
|
|
51
|
+
runtime: {
|
|
52
|
+
state: {
|
|
53
|
+
resolveStateDir: () => dir,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
registerTool: (tool: {execute: ToolExecute}) => {
|
|
57
|
+
registeredExecute = tool.execute
|
|
58
|
+
},
|
|
59
|
+
} as unknown as PluginApi
|
|
60
|
+
|
|
61
|
+
registerSendFileTool(api)
|
|
62
|
+
|
|
63
|
+
return {api, logs, execute: registeredExecute!}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseResult(res: {content: {type: string; text: string}[]}): Record<string, unknown> {
|
|
67
|
+
return JSON.parse(res.content[0].text)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Minimal PNG magic bytes (first 8 bytes)
|
|
71
|
+
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
72
|
+
|
|
73
|
+
let originalFetch: typeof globalThis.fetch
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
mockInjectParams = null
|
|
77
|
+
mockFileTypeFromBuffer = async () => ({mime: 'image/png', ext: 'png'})
|
|
78
|
+
mockFileTypeFromFile = async () => ({mime: 'image/png', ext: 'png'})
|
|
79
|
+
|
|
80
|
+
originalFetch = globalThis.fetch
|
|
81
|
+
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
|
82
|
+
const body = new ReadableStream({
|
|
83
|
+
start(controller) {
|
|
84
|
+
controller.enqueue(PNG_BYTES)
|
|
85
|
+
controller.close()
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
return new Response(body, {
|
|
89
|
+
status: 200,
|
|
90
|
+
headers: {'content-length': String(PNG_BYTES.length)},
|
|
91
|
+
})
|
|
92
|
+
}) as typeof fetch
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
afterAll(() => {
|
|
96
|
+
globalThis.fetch = originalFetch
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// ── Tests ────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe('clawly_send_file', () => {
|
|
102
|
+
describe('validation', () => {
|
|
103
|
+
test('returns error for empty source', async () => {
|
|
104
|
+
const {execute} = createMockApi()
|
|
105
|
+
const res = parseResult(await execute('tc-1', {source: ''}))
|
|
106
|
+
expect(res.error).toBe('source is required')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('returns error for missing source', async () => {
|
|
110
|
+
const {execute} = createMockApi()
|
|
111
|
+
const res = parseResult(await execute('tc-1', {}))
|
|
112
|
+
expect(res.error).toBe('source is required')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('returns error for empty array source', async () => {
|
|
116
|
+
const {execute} = createMockApi()
|
|
117
|
+
const res = parseResult(await execute('tc-1', {source: []}))
|
|
118
|
+
expect(res.error).toBe('source is required')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('returns error for array of empty strings', async () => {
|
|
122
|
+
const {execute} = createMockApi()
|
|
123
|
+
const res = parseResult(await execute('tc-1', {source: ['', ' ', '']}))
|
|
124
|
+
expect(res.error).toBe('source is required')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('URL source', () => {
|
|
129
|
+
test('downloads URL and injects FILE message', async () => {
|
|
130
|
+
const {execute} = createMockApi()
|
|
131
|
+
const res = parseResult(await execute('tc-1', {source: 'https://example.com/image.png'}))
|
|
132
|
+
|
|
133
|
+
expect(res.sent).toBe(true)
|
|
134
|
+
expect(res.count).toBe(1)
|
|
135
|
+
expect(mockInjectParams).not.toBeNull()
|
|
136
|
+
expect(mockInjectParams!.message).toContain('FILE:')
|
|
137
|
+
expect(mockInjectParams!.message).toMatch(/FILE:\/.*\/[a-f0-9]+\.png\?filename=image\.png/)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('rejects URL with executable extension', async () => {
|
|
141
|
+
const {execute} = createMockApi()
|
|
142
|
+
const res = parseResult(await execute('tc-1', {source: 'https://example.com/script.sh'}))
|
|
143
|
+
|
|
144
|
+
expect(res.error).toContain('executable extension')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('rejects downloaded executable by MIME', async () => {
|
|
148
|
+
mockFileTypeFromBuffer = async () => ({
|
|
149
|
+
mime: 'application/x-elf',
|
|
150
|
+
ext: 'elf',
|
|
151
|
+
})
|
|
152
|
+
const {execute} = createMockApi()
|
|
153
|
+
const res = parseResult(await execute('tc-1', {source: 'https://example.com/binary'}))
|
|
154
|
+
|
|
155
|
+
expect(res.error).toContain('executable')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('uses caption when provided', async () => {
|
|
159
|
+
const {execute} = createMockApi()
|
|
160
|
+
await execute('tc-1', {
|
|
161
|
+
source: 'https://example.com/photo.jpg',
|
|
162
|
+
caption: 'Check this out',
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
expect(mockInjectParams!.message).toStartWith('Check this out')
|
|
166
|
+
expect(mockInjectParams!.message).toContain('FILE:')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('local file source', () => {
|
|
171
|
+
test('accepts file under /tmp', async () => {
|
|
172
|
+
const tmpFile = path.join('/tmp', `clawly-send-file-test-${Date.now()}.txt`)
|
|
173
|
+
await fsp.writeFile(tmpFile, 'hello')
|
|
174
|
+
try {
|
|
175
|
+
const {execute} = createMockApi()
|
|
176
|
+
const res = parseResult(await execute('tc-1', {source: tmpFile}))
|
|
177
|
+
|
|
178
|
+
expect(res.sent).toBe(true)
|
|
179
|
+
expect(res.count).toBe(1)
|
|
180
|
+
expect(mockInjectParams!.message).toContain('FILE:')
|
|
181
|
+
} finally {
|
|
182
|
+
await fsp.unlink(tmpFile).catch(() => {})
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('accepts file under $HOME', async () => {
|
|
187
|
+
const homeFile = path.join(os.homedir(), `clawly-send-file-test-${Date.now()}.txt`)
|
|
188
|
+
await fsp.writeFile(homeFile, 'hello')
|
|
189
|
+
try {
|
|
190
|
+
mockFileTypeFromFile = async () => ({mime: 'text/plain', ext: 'txt'})
|
|
191
|
+
const {execute} = createMockApi()
|
|
192
|
+
const res = parseResult(await execute('tc-1', {source: homeFile}))
|
|
193
|
+
|
|
194
|
+
expect(res.sent).toBe(true)
|
|
195
|
+
expect(res.count).toBe(1)
|
|
196
|
+
} finally {
|
|
197
|
+
await fsp.unlink(homeFile).catch(() => {})
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('accepts tilde-expanded path', async () => {
|
|
202
|
+
const homeFile = path.join(os.homedir(), `clawly-send-file-test-${Date.now()}.txt`)
|
|
203
|
+
await fsp.writeFile(homeFile, 'hello')
|
|
204
|
+
try {
|
|
205
|
+
mockFileTypeFromFile = async () => ({mime: 'text/plain', ext: 'txt'})
|
|
206
|
+
const {execute} = createMockApi()
|
|
207
|
+
const res = parseResult(await execute('tc-1', {source: `~/${path.basename(homeFile)}`}))
|
|
208
|
+
|
|
209
|
+
expect(res.sent).toBe(true)
|
|
210
|
+
} finally {
|
|
211
|
+
await fsp.unlink(homeFile).catch(() => {})
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('rejects file outside $HOME and /tmp', async () => {
|
|
216
|
+
const {execute} = createMockApi()
|
|
217
|
+
const res = parseResult(await execute('tc-1', {source: '/etc/hosts'}))
|
|
218
|
+
|
|
219
|
+
expect(res.error).toContain('$HOME or /tmp')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('rejects dot files', async () => {
|
|
223
|
+
const dotFile = path.join('/tmp', `.clawly-send-file-test-${Date.now()}`)
|
|
224
|
+
await fsp.writeFile(dotFile, 'secret')
|
|
225
|
+
try {
|
|
226
|
+
const {execute} = createMockApi()
|
|
227
|
+
const res = parseResult(await execute('tc-1', {source: dotFile}))
|
|
228
|
+
|
|
229
|
+
expect(res.error).toContain('dot files')
|
|
230
|
+
} finally {
|
|
231
|
+
await fsp.unlink(dotFile).catch(() => {})
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('rejects path with dot component (e.g. .openclaw)', async () => {
|
|
236
|
+
const inDotDir = path.join('/tmp', '.openclaw', `test-${Date.now()}.txt`)
|
|
237
|
+
await fsp.mkdir(path.dirname(inDotDir), {recursive: true})
|
|
238
|
+
await fsp.writeFile(inDotDir, 'config')
|
|
239
|
+
try {
|
|
240
|
+
const {execute} = createMockApi()
|
|
241
|
+
const res = parseResult(await execute('tc-1', {source: inDotDir}))
|
|
242
|
+
|
|
243
|
+
expect(res.error).toContain('dot files')
|
|
244
|
+
} finally {
|
|
245
|
+
await fsp.unlink(inDotDir).catch(() => {})
|
|
246
|
+
await fsp.rmdir(path.dirname(inDotDir)).catch(() => {})
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('rejects executable by extension', async () => {
|
|
251
|
+
const shFile = path.join('/tmp', `clawly-send-file-test-${Date.now()}.sh`)
|
|
252
|
+
await fsp.writeFile(shFile, '#!/bin/bash\necho hi')
|
|
253
|
+
try {
|
|
254
|
+
const {execute} = createMockApi()
|
|
255
|
+
const res = parseResult(await execute('tc-1', {source: shFile}))
|
|
256
|
+
|
|
257
|
+
expect(res.error).toContain('executable')
|
|
258
|
+
} finally {
|
|
259
|
+
await fsp.unlink(shFile).catch(() => {})
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('rejects executable by MIME', async () => {
|
|
264
|
+
const binFile = path.join('/tmp', `clawly-send-file-test-${Date.now()}.bin`)
|
|
265
|
+
await fsp.writeFile(binFile, '\x7fELF')
|
|
266
|
+
try {
|
|
267
|
+
mockFileTypeFromFile = async () => ({
|
|
268
|
+
mime: 'application/x-elf',
|
|
269
|
+
ext: 'elf',
|
|
270
|
+
})
|
|
271
|
+
const {execute} = createMockApi()
|
|
272
|
+
const res = parseResult(await execute('tc-1', {source: binFile}))
|
|
273
|
+
|
|
274
|
+
expect(res.error).toContain('executable')
|
|
275
|
+
} finally {
|
|
276
|
+
await fsp.unlink(binFile).catch(() => {})
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('returns error for non-existent file', async () => {
|
|
281
|
+
const {execute} = createMockApi()
|
|
282
|
+
const res = parseResult(await execute('tc-1', {source: '/tmp/nonexistent-file-12345.txt'}))
|
|
283
|
+
|
|
284
|
+
expect(res.error).toContain('file not found')
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('state dir restriction', () => {
|
|
289
|
+
test('rejects file directly under state dir', async () => {
|
|
290
|
+
const stateDir = path.join('/tmp', `.clawly-state-test-${Date.now()}`)
|
|
291
|
+
const stateFile = path.join(stateDir, 'config.json')
|
|
292
|
+
await fsp.mkdir(stateDir, {recursive: true})
|
|
293
|
+
await fsp.writeFile(stateFile, '{}')
|
|
294
|
+
try {
|
|
295
|
+
mockFileTypeFromFile = async () => ({mime: 'application/json', ext: 'json'})
|
|
296
|
+
const {execute} = createMockApi(stateDir)
|
|
297
|
+
const res = parseResult(await execute('tc-1', {source: stateFile}))
|
|
298
|
+
expect(res.error).toContain('state directory')
|
|
299
|
+
} finally {
|
|
300
|
+
await fsp.rm(stateDir, {recursive: true}).catch(() => {})
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('rejects file in non-allowed state subdir', async () => {
|
|
305
|
+
const stateDir = path.join('/tmp', `.clawly-state-test-${Date.now()}`)
|
|
306
|
+
const secretFile = path.join(stateDir, 'agents', 'secret.txt')
|
|
307
|
+
await fsp.mkdir(path.dirname(secretFile), {recursive: true})
|
|
308
|
+
await fsp.writeFile(secretFile, 'secret')
|
|
309
|
+
try {
|
|
310
|
+
mockFileTypeFromFile = async () => ({mime: 'text/plain', ext: 'txt'})
|
|
311
|
+
const {execute} = createMockApi(stateDir)
|
|
312
|
+
const res = parseResult(await execute('tc-1', {source: secretFile}))
|
|
313
|
+
expect(res.error).toContain('state directory')
|
|
314
|
+
} finally {
|
|
315
|
+
await fsp.rm(stateDir, {recursive: true}).catch(() => {})
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('allows file under state dir clawly/', async () => {
|
|
320
|
+
const stateDir = path.join('/tmp', `.clawly-state-test-${Date.now()}`)
|
|
321
|
+
const allowedFile = path.join(stateDir, 'clawly', 'data.txt')
|
|
322
|
+
await fsp.mkdir(path.dirname(allowedFile), {recursive: true})
|
|
323
|
+
await fsp.writeFile(allowedFile, 'ok')
|
|
324
|
+
try {
|
|
325
|
+
mockFileTypeFromFile = async () => ({mime: 'text/plain', ext: 'txt'})
|
|
326
|
+
const {execute} = createMockApi(stateDir)
|
|
327
|
+
const res = parseResult(await execute('tc-1', {source: allowedFile}))
|
|
328
|
+
expect(res.sent).toBe(true)
|
|
329
|
+
} finally {
|
|
330
|
+
await fsp.rm(stateDir, {recursive: true}).catch(() => {})
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('allows file under state dir canvas/', async () => {
|
|
335
|
+
const stateDir = path.join('/tmp', `.clawly-state-test-${Date.now()}`)
|
|
336
|
+
const allowedFile = path.join(stateDir, 'canvas', 'sketch.png')
|
|
337
|
+
await fsp.mkdir(path.dirname(allowedFile), {recursive: true})
|
|
338
|
+
await fsp.writeFile(allowedFile, 'ok')
|
|
339
|
+
try {
|
|
340
|
+
mockFileTypeFromFile = async () => ({mime: 'image/png', ext: 'png'})
|
|
341
|
+
const {execute} = createMockApi(stateDir)
|
|
342
|
+
const res = parseResult(await execute('tc-1', {source: allowedFile}))
|
|
343
|
+
expect(res.sent).toBe(true)
|
|
344
|
+
} finally {
|
|
345
|
+
await fsp.rm(stateDir, {recursive: true}).catch(() => {})
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
test('allows file under state dir memory/', async () => {
|
|
350
|
+
const stateDir = path.join('/tmp', `.clawly-state-test-${Date.now()}`)
|
|
351
|
+
const allowedFile = path.join(stateDir, 'memory', 'note.md')
|
|
352
|
+
await fsp.mkdir(path.dirname(allowedFile), {recursive: true})
|
|
353
|
+
await fsp.writeFile(allowedFile, 'ok')
|
|
354
|
+
try {
|
|
355
|
+
mockFileTypeFromFile = async () => ({mime: 'text/plain', ext: 'txt'})
|
|
356
|
+
const {execute} = createMockApi(stateDir)
|
|
357
|
+
const res = parseResult(await execute('tc-1', {source: allowedFile}))
|
|
358
|
+
expect(res.sent).toBe(true)
|
|
359
|
+
} finally {
|
|
360
|
+
await fsp.rm(stateDir, {recursive: true}).catch(() => {})
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
describe('array source', () => {
|
|
366
|
+
test('processes multiple sources', async () => {
|
|
367
|
+
const tmp1 = path.join('/tmp', `clawly-send-file-a-${Date.now()}.txt`)
|
|
368
|
+
const tmp2 = path.join('/tmp', `clawly-send-file-b-${Date.now()}.txt`)
|
|
369
|
+
await fsp.writeFile(tmp1, 'a')
|
|
370
|
+
await fsp.writeFile(tmp2, 'b')
|
|
371
|
+
try {
|
|
372
|
+
mockFileTypeFromFile = async () => ({mime: 'text/plain', ext: 'txt'})
|
|
373
|
+
const {execute} = createMockApi()
|
|
374
|
+
const res = parseResult(await execute('tc-1', {source: [tmp1, tmp2]}))
|
|
375
|
+
|
|
376
|
+
expect(res.sent).toBe(true)
|
|
377
|
+
expect(res.count).toBe(2)
|
|
378
|
+
expect(mockInjectParams!.message.split('FILE:').length - 1).toBe(2)
|
|
379
|
+
} finally {
|
|
380
|
+
await fsp.unlink(tmp1).catch(() => {})
|
|
381
|
+
await fsp.unlink(tmp2).catch(() => {})
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('filters empty strings from array', async () => {
|
|
386
|
+
const tmpFile = path.join('/tmp', `clawly-send-file-${Date.now()}.txt`)
|
|
387
|
+
await fsp.writeFile(tmpFile, 'x')
|
|
388
|
+
try {
|
|
389
|
+
mockFileTypeFromFile = async () => ({mime: 'text/plain', ext: 'txt'})
|
|
390
|
+
const {execute} = createMockApi()
|
|
391
|
+
const res = parseResult(await execute('tc-1', {source: ['', tmpFile, ' ', tmpFile]}))
|
|
392
|
+
|
|
393
|
+
expect(res.sent).toBe(true)
|
|
394
|
+
expect(res.count).toBe(2)
|
|
395
|
+
} finally {
|
|
396
|
+
await fsp.unlink(tmpFile).catch(() => {})
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
})
|