@2en/clawly-plugins 1.30.0-beta.1 → 1.30.0-beta.10
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/auto-pair.ts +1 -1
- package/clawly-config-defaults.json5 +132 -13
- package/config-setup.ts +146 -326
- package/gateway/config-model.ts +74 -0
- package/gateway/config-timezone.ts +25 -14
- package/gateway/cron-delivery.test.ts +16 -0
- package/gateway/index.ts +2 -2
- package/gateway/offline-push.test.ts +82 -20
- package/gateway/offline-push.ts +36 -8
- package/gateway/presence.ts +7 -4
- package/gateway-fetch.ts +12 -5
- package/index.ts +2 -2
- package/lib/calendar-cache.ts +10 -1
- package/model-gateway-setup.ts +46 -0
- package/openclaw.plugin.json +1 -0
- package/outbound.ts +20 -24
- package/package.json +4 -5
- package/tools/clawly-calendar.ts +44 -0
- package/tools/clawly-search.test.ts +48 -1
- package/tools/clawly-search.ts +14 -0
- package/tools/clawly-send-file.test.ts +400 -0
- package/tools/clawly-send-file.ts +307 -0
- package/tools/create-search-tool.ts +27 -4
- package/tools/index.ts +9 -3
- package/types.ts +1 -1
- package/gateway/node-dangerous-allowlist.ts +0 -81
- package/tools/clawly-send-image.ts +0 -228
package/outbound.ts
CHANGED
|
@@ -18,6 +18,7 @@ import fsp from 'node:fs/promises'
|
|
|
18
18
|
import type {IncomingMessage, ServerResponse} from 'node:http'
|
|
19
19
|
import os from 'node:os'
|
|
20
20
|
import path from 'node:path'
|
|
21
|
+
import mime from 'mime'
|
|
21
22
|
|
|
22
23
|
import type {PluginApi} from './index'
|
|
23
24
|
import {createAccessToken, guardHttpAuth, resolveGatewaySecret, sendJson} from './lib/httpAuth'
|
|
@@ -132,24 +133,7 @@ export function registerOutboundMethods(api: PluginApi) {
|
|
|
132
133
|
|
|
133
134
|
// ── HTTP route: GET /clawly/file/outbound?path=<original-path> ─────────────
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
'.mp3': 'audio/mpeg',
|
|
137
|
-
'.wav': 'audio/wav',
|
|
138
|
-
'.ogg': 'audio/ogg',
|
|
139
|
-
'.m4a': 'audio/mp4',
|
|
140
|
-
'.aac': 'audio/aac',
|
|
141
|
-
'.flac': 'audio/flac',
|
|
142
|
-
'.webm': 'audio/webm',
|
|
143
|
-
'.jpg': 'image/jpeg',
|
|
144
|
-
'.jpeg': 'image/jpeg',
|
|
145
|
-
'.png': 'image/png',
|
|
146
|
-
'.gif': 'image/gif',
|
|
147
|
-
'.webp': 'image/webp',
|
|
148
|
-
'.bmp': 'image/bmp',
|
|
149
|
-
'.heic': 'image/heic',
|
|
150
|
-
'.avif': 'image/avif',
|
|
151
|
-
'.ico': 'image/x-icon',
|
|
152
|
-
}
|
|
136
|
+
// Content-Type resolution via `mime` package (replaces hardcoded map)
|
|
153
137
|
|
|
154
138
|
/** Directories from which direct-path serving is allowed (no hash required). */
|
|
155
139
|
let allowedRoots: string[] | null = null
|
|
@@ -183,6 +167,12 @@ async function resolveOutboundFile(rawPath: string, stateDir?: string): Promise<
|
|
|
183
167
|
return null
|
|
184
168
|
}
|
|
185
169
|
|
|
170
|
+
export function buildContentDisposition(filename: string): string {
|
|
171
|
+
const asciiName = filename.replace(/[^\x20-\x7E]/g, '_').replace(/["\\]/g, '\\$&')
|
|
172
|
+
const utf8Name = encodeURIComponent(filename)
|
|
173
|
+
return `attachment; filename="${asciiName}"; filename*=UTF-8''${utf8Name}`
|
|
174
|
+
}
|
|
175
|
+
|
|
186
176
|
export function registerOutboundHttpRoute(api: PluginApi) {
|
|
187
177
|
const stateDir = api.runtime.state.resolveStateDir()
|
|
188
178
|
|
|
@@ -219,8 +209,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
219
209
|
return
|
|
220
210
|
}
|
|
221
211
|
|
|
222
|
-
const
|
|
223
|
-
const contentType = MIME[ext] ?? 'application/octet-stream'
|
|
212
|
+
const contentType = mime.getType(resolved) ?? 'application/octet-stream'
|
|
224
213
|
const stat = await fsp.stat(resolved)
|
|
225
214
|
const total = stat.size
|
|
226
215
|
|
|
@@ -230,6 +219,15 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
230
219
|
return
|
|
231
220
|
}
|
|
232
221
|
|
|
222
|
+
const downloadFilename = url.searchParams.get('download')
|
|
223
|
+
const baseHeaders: Record<string, string | number> = {
|
|
224
|
+
'Content-Type': contentType,
|
|
225
|
+
'Accept-Ranges': 'bytes',
|
|
226
|
+
}
|
|
227
|
+
if (downloadFilename && path.extname(downloadFilename) === path.extname(resolved)) {
|
|
228
|
+
baseHeaders['Content-Disposition'] = buildContentDisposition(downloadFilename)
|
|
229
|
+
}
|
|
230
|
+
|
|
233
231
|
const rangeHeader = _req.headers.range
|
|
234
232
|
|
|
235
233
|
if (rangeHeader) {
|
|
@@ -241,9 +239,8 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
241
239
|
const stream = fs.createReadStream(resolved, {start, end})
|
|
242
240
|
|
|
243
241
|
res.writeHead(206, {
|
|
244
|
-
|
|
242
|
+
...baseHeaders,
|
|
245
243
|
'Content-Range': `bytes ${start}-${end}/${total}`,
|
|
246
|
-
'Accept-Ranges': 'bytes',
|
|
247
244
|
'Content-Length': chunkSize,
|
|
248
245
|
})
|
|
249
246
|
stream.pipe(res)
|
|
@@ -256,9 +253,8 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
256
253
|
|
|
257
254
|
const buffer = await fsp.readFile(resolved)
|
|
258
255
|
res.writeHead(200, {
|
|
259
|
-
|
|
256
|
+
...baseHeaders,
|
|
260
257
|
'Content-Length': total,
|
|
261
|
-
'Accept-Ranges': 'bytes',
|
|
262
258
|
})
|
|
263
259
|
res.end(buffer)
|
|
264
260
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.30.0-beta.
|
|
3
|
+
"version": "1.30.0-beta.10",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
"@opentelemetry/exporter-logs-otlp-http": "^0.57.0",
|
|
14
14
|
"@opentelemetry/resources": "^1.30.0",
|
|
15
15
|
"@opentelemetry/sdk-logs": "^0.57.0",
|
|
16
|
-
"posthog-node": "^5.28.0",
|
|
17
16
|
"file-type": "^21.3.0",
|
|
17
|
+
"json5": "^2.2.3",
|
|
18
|
+
"mime": "^4.1.0",
|
|
19
|
+
"posthog-node": "^5.28.0",
|
|
18
20
|
"zx": "npm:zx@8.8.5-lite"
|
|
19
21
|
},
|
|
20
22
|
"files": [
|
|
@@ -47,8 +49,5 @@
|
|
|
47
49
|
"extensions": [
|
|
48
50
|
"./index.ts"
|
|
49
51
|
]
|
|
50
|
-
},
|
|
51
|
-
"devDependencies": {
|
|
52
|
-
"json5": "^2.2.3"
|
|
53
52
|
}
|
|
54
53
|
}
|
package/tools/clawly-calendar.ts
CHANGED
|
@@ -158,6 +158,26 @@ export function registerCalendarTools(api: PluginApi) {
|
|
|
158
158
|
location: {type: 'string', description: 'Event location'},
|
|
159
159
|
notes: {type: 'string', description: 'Event description/notes'},
|
|
160
160
|
timeZone: {type: 'string', description: 'IANA time zone (e.g. America/New_York)'},
|
|
161
|
+
alarms: {
|
|
162
|
+
type: 'array',
|
|
163
|
+
description:
|
|
164
|
+
'Array of alarm objects to set reminders. Each alarm has a relativeOffset in minutes (negative = before event). Example: [{relativeOffset: -15}] for 15 minutes before. Only alarms with valid relativeOffset (number) or method (string) will be applied. To verify reminders were set, use clawly_calendar_list_events after creation.',
|
|
165
|
+
items: {
|
|
166
|
+
type: 'object',
|
|
167
|
+
properties: {
|
|
168
|
+
relativeOffset: {
|
|
169
|
+
type: 'number',
|
|
170
|
+
description:
|
|
171
|
+
'Minutes relative to the event start time. Use negative values for reminders before the event (e.g. -15 = 15 min before, -60 = 1 hour before).',
|
|
172
|
+
},
|
|
173
|
+
method: {
|
|
174
|
+
type: 'string',
|
|
175
|
+
enum: ['alarm', 'alert', 'email', 'default', 'sms'],
|
|
176
|
+
description: 'Alarm method (Android only). iOS always uses notification.',
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
161
181
|
},
|
|
162
182
|
},
|
|
163
183
|
async execute(_toolCallId, params) {
|
|
@@ -211,6 +231,9 @@ export function registerCalendarTools(api: PluginApi) {
|
|
|
211
231
|
...(params.location ? {location: params.location} : {}),
|
|
212
232
|
...(params.notes ? {notes: params.notes} : {}),
|
|
213
233
|
...(params.timeZone ? {timeZone: params.timeZone} : {}),
|
|
234
|
+
...(Array.isArray(params.alarms) && params.alarms.length > 0
|
|
235
|
+
? {alarms: params.alarms}
|
|
236
|
+
: {}),
|
|
214
237
|
}
|
|
215
238
|
|
|
216
239
|
const resultPromise = waitForActionResult(actionId)
|
|
@@ -290,6 +313,26 @@ export function registerCalendarTools(api: PluginApi) {
|
|
|
290
313
|
location: {type: 'string', description: 'New event location'},
|
|
291
314
|
notes: {type: 'string', description: 'New event description/notes'},
|
|
292
315
|
timeZone: {type: 'string', description: 'IANA time zone (e.g. America/New_York)'},
|
|
316
|
+
alarms: {
|
|
317
|
+
type: 'array',
|
|
318
|
+
description:
|
|
319
|
+
'Array of alarm objects to set reminders. Each alarm has a relativeOffset in minutes (negative = before event). Example: [{relativeOffset: -15}] for 15 minutes before. Replaces all existing alarms. Pass an empty array [] to clear all existing reminders.',
|
|
320
|
+
items: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
properties: {
|
|
323
|
+
relativeOffset: {
|
|
324
|
+
type: 'number',
|
|
325
|
+
description:
|
|
326
|
+
'Minutes relative to the event start time. Use negative values for reminders before the event (e.g. -15 = 15 min before, -60 = 1 hour before).',
|
|
327
|
+
},
|
|
328
|
+
method: {
|
|
329
|
+
type: 'string',
|
|
330
|
+
enum: ['alarm', 'alert', 'email', 'default', 'sms'],
|
|
331
|
+
description: 'Alarm method (Android only). iOS always uses notification.',
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
},
|
|
293
336
|
},
|
|
294
337
|
},
|
|
295
338
|
async execute(_toolCallId, params) {
|
|
@@ -314,6 +357,7 @@ export function registerCalendarTools(api: PluginApi) {
|
|
|
314
357
|
...(params.location !== undefined ? {location: params.location} : {}),
|
|
315
358
|
...(params.notes !== undefined ? {notes: params.notes} : {}),
|
|
316
359
|
...(params.timeZone !== undefined ? {timeZone: params.timeZone} : {}),
|
|
360
|
+
...(Array.isArray(params.alarms) ? {alarms: params.alarms} : {}),
|
|
317
361
|
}
|
|
318
362
|
|
|
319
363
|
const resultPromise = waitForActionResult(actionId)
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import {afterAll, beforeEach, describe, expect, test} from 'bun:test'
|
|
2
2
|
import type {PluginApi} from '../types'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
registerDeepSearchTool,
|
|
5
|
+
registerGrokSearchTool,
|
|
6
|
+
registerKimiSearchTool,
|
|
7
|
+
registerSearchTool,
|
|
8
|
+
} from './clawly-search'
|
|
4
9
|
|
|
5
10
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
6
11
|
|
|
@@ -267,3 +272,45 @@ describe('clawly_grok_search', () => {
|
|
|
267
272
|
expect(res.error).toContain('Grok API error 429')
|
|
268
273
|
})
|
|
269
274
|
})
|
|
275
|
+
|
|
276
|
+
// ── clawly_kimi_search ──────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
describe('clawly_kimi_search', () => {
|
|
279
|
+
test('uses Kimi gateway endpoint with web_search tool', async () => {
|
|
280
|
+
mockResponse.body = {
|
|
281
|
+
choices: [{message: {content: 'Kimi answer with [source](https://example.com/page)'}}],
|
|
282
|
+
}
|
|
283
|
+
const {execute} = createMockApi(registerKimiSearchTool)
|
|
284
|
+
const res = parseResult(await execute('tc-1', {query: 'Chinese news'}))
|
|
285
|
+
|
|
286
|
+
expect(fetchCalls).toHaveLength(1)
|
|
287
|
+
const call = fetchCalls[0]
|
|
288
|
+
expect(call.url).toBe('https://gw.example.com/v1/kimi/v1/chat/completions')
|
|
289
|
+
expect(call.body?.model).toBe('kimi-k2-turbo-preview')
|
|
290
|
+
expect(call.body?.stream).toBe(false)
|
|
291
|
+
expect(call.body?.messages).toEqual([{role: 'user', content: 'Chinese news'}])
|
|
292
|
+
expect(call.body?.tools).toEqual([{type: 'builtin_function', function: {name: 'web_search'}}])
|
|
293
|
+
|
|
294
|
+
expect(res.answer).toBe('Kimi answer with [source](https://example.com/page)')
|
|
295
|
+
expect(res.citations).toEqual(['https://example.com/page'])
|
|
296
|
+
expect(res.provider).toBe('kimi')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('returns empty citations when response has no inline links', async () => {
|
|
300
|
+
mockResponse.body = {choices: [{message: {content: 'Plain answer'}}]}
|
|
301
|
+
const {execute} = createMockApi(registerKimiSearchTool)
|
|
302
|
+
const res = parseResult(await execute('tc-1', {query: 'test'}))
|
|
303
|
+
|
|
304
|
+
expect(res.answer).toBe('Plain answer')
|
|
305
|
+
expect(res.citations).toEqual([])
|
|
306
|
+
expect(res.provider).toBe('kimi')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('returns error on Kimi API failure', async () => {
|
|
310
|
+
mockResponse = {ok: false, status: 429, body: {error: 'rate limited'}}
|
|
311
|
+
const {execute} = createMockApi(registerKimiSearchTool)
|
|
312
|
+
const res = parseResult(await execute('tc-1', {query: 'test'}))
|
|
313
|
+
|
|
314
|
+
expect(res.error).toContain('Kimi API error 429')
|
|
315
|
+
})
|
|
316
|
+
})
|
package/tools/clawly-search.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildGrokBody,
|
|
3
3
|
buildGrokUrl,
|
|
4
|
+
buildKimiBody,
|
|
5
|
+
buildKimiUrl,
|
|
4
6
|
buildPerplexityUrl,
|
|
5
7
|
createSearchToolRegistrar,
|
|
6
8
|
parseGrokResponse,
|
|
9
|
+
parseKimiResponse,
|
|
7
10
|
} from './create-search-tool'
|
|
8
11
|
|
|
9
12
|
export const registerSearchTool = createSearchToolRegistrar({
|
|
@@ -40,3 +43,14 @@ export const registerGrokSearchTool = createSearchToolRegistrar({
|
|
|
40
43
|
buildBody: buildGrokBody,
|
|
41
44
|
parseResponse: parseGrokResponse,
|
|
42
45
|
})
|
|
46
|
+
|
|
47
|
+
export const registerKimiSearchTool = createSearchToolRegistrar({
|
|
48
|
+
toolName: 'clawly_kimi_search',
|
|
49
|
+
description: 'Search the web using Kimi. Good for Chinese-language queries and content.',
|
|
50
|
+
model: 'kimi-k2-turbo-preview',
|
|
51
|
+
buildUrl: buildKimiUrl,
|
|
52
|
+
timeoutMs: 30_000,
|
|
53
|
+
provider: 'kimi',
|
|
54
|
+
buildBody: buildKimiBody,
|
|
55
|
+
parseResponse: parseKimiResponse,
|
|
56
|
+
})
|
|
@@ -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
|
+
})
|