@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/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
- const MIME: Record<string, string> = {
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 ext = path.extname(resolved).toLowerCase()
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
- 'Content-Type': contentType,
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
- 'Content-Type': contentType,
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.1",
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
  }
@@ -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 {registerDeepSearchTool, registerGrokSearchTool, registerSearchTool} from './clawly-search'
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
+ })
@@ -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
+ })