@2en/clawly-plugins 1.30.0-beta.2 → 1.30.0-beta.3
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/index.ts +1 -1
- package/outbound.ts +20 -24
- package/package.json +3 -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/tools/clawly-send-image.ts +0 -228
package/index.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Agent tools:
|
|
18
18
|
* - clawly_is_user_online — check if user's device is connected
|
|
19
19
|
* - clawly_send_app_push — send a push notification to user's device
|
|
20
|
-
* -
|
|
20
|
+
* - clawly_send_file — send a file to the user (URL or local path under $HOME/tmp)
|
|
21
21
|
* - clawly_search — web search via Perplexity (replaces denied web_search)
|
|
22
22
|
* - clawly_send_message — send a message to user via main session agent (supports role: user|assistant)
|
|
23
23
|
*
|
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.3",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -13,8 +13,9 @@
|
|
|
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
|
+
"mime": "^4.1.0",
|
|
18
|
+
"posthog-node": "^5.28.0",
|
|
18
19
|
"zx": "npm:zx@8.8.5-lite"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
@@ -47,8 +48,5 @@
|
|
|
47
48
|
"extensions": [
|
|
48
49
|
"./index.ts"
|
|
49
50
|
]
|
|
50
|
-
},
|
|
51
|
-
"devDependencies": {
|
|
52
|
-
"json5": "^2.2.3"
|
|
53
51
|
}
|
|
54
52
|
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tool: clawly_send_file — send a file to the user.
|
|
3
|
+
*
|
|
4
|
+
* Accepts a URL (https/http) or a local file path (or arrays of either).
|
|
5
|
+
* URLs are downloaded and cached locally. Injects a FILE:<path>?<filename> assistant
|
|
6
|
+
* message via chat.inject so the mobile client can render or display the file.
|
|
7
|
+
*
|
|
8
|
+
* Restrictions:
|
|
9
|
+
* - URLs: executable files are rejected (by MIME/extension).
|
|
10
|
+
* - Local files:
|
|
11
|
+
* - Dot files rejected (e.g. ~/.openclaw, .env, .bashrc).
|
|
12
|
+
* - Only files under $HOME or /tmp (files outside home are rejected unless in /tmp).
|
|
13
|
+
* - State dir ($OPENCLAW_STATE_DIR) blocked, except clawly/, canvas/, memory/ subdirs.
|
|
14
|
+
* - Executable files rejected (binaries + script extensions).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import crypto from 'node:crypto'
|
|
18
|
+
import fsp from 'node:fs/promises'
|
|
19
|
+
import os from 'node:os'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import {fileTypeFromBuffer, fileTypeFromFile} from 'file-type'
|
|
22
|
+
import {injectAssistantMessage, resolveSessionKey} from '../gateway/inject'
|
|
23
|
+
import type {PluginApi} from '../types'
|
|
24
|
+
|
|
25
|
+
const TOOL_NAME = 'clawly_send_file'
|
|
26
|
+
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB
|
|
27
|
+
|
|
28
|
+
const parameters: Record<string, unknown> = {
|
|
29
|
+
type: 'object',
|
|
30
|
+
required: ['source'],
|
|
31
|
+
properties: {
|
|
32
|
+
source: {
|
|
33
|
+
oneOf: [
|
|
34
|
+
{type: 'string', description: 'File URL (https://...) or local file path'},
|
|
35
|
+
{
|
|
36
|
+
type: 'array',
|
|
37
|
+
items: {type: 'string'},
|
|
38
|
+
description: 'Array of file URLs or local file paths',
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
caption: {type: 'string', description: 'Optional caption text'},
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Executable blocklist ───────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/** MIME types for binary executables (ELF, Mach-O, PE, etc.) */
|
|
49
|
+
const EXECUTABLE_MIMES = new Set([
|
|
50
|
+
'application/x-executable',
|
|
51
|
+
'application/x-elf',
|
|
52
|
+
'application/x-sharedlib',
|
|
53
|
+
'application/x-pie-executable',
|
|
54
|
+
'application/x-mach-binary',
|
|
55
|
+
'application/x-mach-binary-fat',
|
|
56
|
+
'application/x-msdownload',
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
/** Extensions for executables and executable scripts */
|
|
60
|
+
const EXECUTABLE_EXTS = new Set([
|
|
61
|
+
'.exe',
|
|
62
|
+
'.com',
|
|
63
|
+
'.bat',
|
|
64
|
+
'.cmd',
|
|
65
|
+
'.sh',
|
|
66
|
+
'.bash',
|
|
67
|
+
'.csh',
|
|
68
|
+
'.zsh',
|
|
69
|
+
'.ps1',
|
|
70
|
+
'.psm1',
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
function isExecutableByMime(mime?: string): boolean {
|
|
74
|
+
return !!mime && EXECUTABLE_MIMES.has(mime)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isExecutableByExt(filePath: string): boolean {
|
|
78
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
79
|
+
return EXECUTABLE_EXTS.has(ext)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasDotFileComponent(filePath: string): boolean {
|
|
83
|
+
const normalized = path.normalize(filePath)
|
|
84
|
+
const parts = normalized.split(path.sep)
|
|
85
|
+
return parts.some((p) => p.startsWith('.') && p.length > 1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isUnderAllowedRoot(filePath: string): boolean {
|
|
89
|
+
const resolved = path.resolve(filePath)
|
|
90
|
+
const home = os.homedir()
|
|
91
|
+
return resolved.startsWith(home + path.sep) || resolved === home || resolved.startsWith('/tmp/')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function downloadsDir(api: PluginApi): string {
|
|
97
|
+
return path.join(api.runtime.state.resolveStateDir(), 'clawly', 'downloads')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extFromUrl(url: string): string {
|
|
101
|
+
try {
|
|
102
|
+
const pathname = new URL(url).pathname
|
|
103
|
+
const ext = path.extname(pathname).toLowerCase()
|
|
104
|
+
if (ext) return ext
|
|
105
|
+
} catch {}
|
|
106
|
+
return ''
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function localPath(source: string, ext: string, api: PluginApi): string {
|
|
110
|
+
const hash = crypto.createHash('sha256').update(source).digest('hex')
|
|
111
|
+
return path.join(downloadsDir(api), `${hash}${ext || ''}`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
115
|
+
try {
|
|
116
|
+
await fsp.access(p)
|
|
117
|
+
return true
|
|
118
|
+
} catch {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sourceFilename(source: string): string {
|
|
124
|
+
try {
|
|
125
|
+
return path.basename(new URL(source).pathname) || path.basename(source)
|
|
126
|
+
} catch {
|
|
127
|
+
return path.basename(source)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatMediaMessage(filePaths: string[], sources: string[], caption?: string): string {
|
|
132
|
+
const parts: string[] = []
|
|
133
|
+
if (caption) parts.push(caption)
|
|
134
|
+
for (let i = 0; i < filePaths.length; i++) {
|
|
135
|
+
const filename = sourceFilename(sources[i] ?? filePaths[i])
|
|
136
|
+
const pathBasename = path.basename(filePaths[i])
|
|
137
|
+
const qs = filename !== pathBasename ? `?filename=${encodeURIComponent(filename)}` : ''
|
|
138
|
+
parts.push(`FILE:${filePaths[i]}${qs}`)
|
|
139
|
+
}
|
|
140
|
+
return parts.join('\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Download ─────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000
|
|
146
|
+
|
|
147
|
+
async function downloadFile(url: string, api: PluginApi): Promise<string> {
|
|
148
|
+
const dir = downloadsDir(api)
|
|
149
|
+
await fsp.mkdir(dir, {recursive: true})
|
|
150
|
+
|
|
151
|
+
const controller = new AbortController()
|
|
152
|
+
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS)
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetch(url, {redirect: 'follow', signal: controller.signal})
|
|
155
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`)
|
|
156
|
+
|
|
157
|
+
const contentLength = res.headers.get('content-length')
|
|
158
|
+
if (contentLength && Number(contentLength) > MAX_DOWNLOAD_BYTES) {
|
|
159
|
+
throw new Error(`file too large (${contentLength} bytes, max ${MAX_DOWNLOAD_BYTES})`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const chunks: Uint8Array[] = []
|
|
163
|
+
let totalSize = 0
|
|
164
|
+
const reader = res.body?.getReader()
|
|
165
|
+
if (!reader) throw new Error('no response body')
|
|
166
|
+
|
|
167
|
+
while (true) {
|
|
168
|
+
const {done, value} = await reader.read()
|
|
169
|
+
if (done) break
|
|
170
|
+
totalSize += value.byteLength
|
|
171
|
+
if (totalSize > MAX_DOWNLOAD_BYTES) {
|
|
172
|
+
reader.cancel()
|
|
173
|
+
throw new Error(`file too large (exceeded ${MAX_DOWNLOAD_BYTES} bytes)`)
|
|
174
|
+
}
|
|
175
|
+
chunks.push(value)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const buffer = Buffer.concat(chunks)
|
|
179
|
+
const type = await fileTypeFromBuffer(buffer)
|
|
180
|
+
|
|
181
|
+
if (type && isExecutableByMime(type.mime)) {
|
|
182
|
+
throw new Error('downloaded file is an executable and cannot be sent')
|
|
183
|
+
}
|
|
184
|
+
const urlExt = extFromUrl(url)
|
|
185
|
+
if (urlExt && EXECUTABLE_EXTS.has(urlExt)) {
|
|
186
|
+
throw new Error('downloaded file has executable extension and cannot be sent')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Use urlExt for cache key so resolveSource cache lookup matches
|
|
190
|
+
const dest = localPath(url, urlExt, api)
|
|
191
|
+
await fsp.writeFile(dest, buffer)
|
|
192
|
+
api.logger.info(`${TOOL_NAME}: downloaded ${url} -> ${dest} (${buffer.length} bytes)`)
|
|
193
|
+
return dest
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timeout)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Tool registration ────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export function registerSendFileTool(api: PluginApi) {
|
|
202
|
+
api.registerTool({
|
|
203
|
+
name: TOOL_NAME,
|
|
204
|
+
description:
|
|
205
|
+
'Send one or more files to the user. Accepts a single URL/path or an array of URLs/paths. Supports images, documents, audio, etc. Executable files and dot files are not allowed. Local files must be under $HOME or /tmp.',
|
|
206
|
+
parameters,
|
|
207
|
+
async execute(_toolCallId, params) {
|
|
208
|
+
let sources: string[]
|
|
209
|
+
if (typeof params.source === 'string') {
|
|
210
|
+
sources = [params.source.trim()].filter(Boolean)
|
|
211
|
+
} else if (Array.isArray(params.source)) {
|
|
212
|
+
sources = params.source
|
|
213
|
+
.filter((s): s is string => typeof s === 'string')
|
|
214
|
+
.map((s) => s.trim())
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
} else {
|
|
217
|
+
sources = []
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (sources.length === 0) {
|
|
221
|
+
return {content: [{type: 'text', text: JSON.stringify({error: 'source is required'})}]}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const caption = typeof params.caption === 'string' ? params.caption.trim() : undefined
|
|
225
|
+
|
|
226
|
+
const resolveSource = async (source: string): Promise<string> => {
|
|
227
|
+
if (source.startsWith('https://') || source.startsWith('http://')) {
|
|
228
|
+
const guessExt = extFromUrl(source)
|
|
229
|
+
if (guessExt && EXECUTABLE_EXTS.has(guessExt)) {
|
|
230
|
+
throw new Error(`${source}: URL has executable extension and cannot be sent`)
|
|
231
|
+
}
|
|
232
|
+
const cached = localPath(source, guessExt, api)
|
|
233
|
+
if (await fileExists(cached)) {
|
|
234
|
+
api.logger.info(`${TOOL_NAME}: serving cached ${cached}`)
|
|
235
|
+
return cached
|
|
236
|
+
}
|
|
237
|
+
return await downloadFile(source, api)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Local file source
|
|
241
|
+
const resolved = path.resolve(source.replace(/^~/, os.homedir()))
|
|
242
|
+
if (!(await fileExists(resolved))) {
|
|
243
|
+
throw new Error(`${source}: file not found`)
|
|
244
|
+
}
|
|
245
|
+
if (!isUnderAllowedRoot(resolved)) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`${source}: file must be under $HOME or /tmp (files outside home are not allowed)`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
// Block state dir files except clawly/, canvas/, memory/ subdirs.
|
|
251
|
+
// This check runs BEFORE hasDotFileComponent because the state dir
|
|
252
|
+
// itself may contain a dot component (e.g. ~/.openclaw).
|
|
253
|
+
const stateDir = api.runtime.state.resolveStateDir()
|
|
254
|
+
const stateDirPrefix = stateDir + path.sep
|
|
255
|
+
const isUnderStateDir = resolved.startsWith(stateDirPrefix) || resolved === stateDir
|
|
256
|
+
if (isUnderStateDir) {
|
|
257
|
+
const allowedPrefixes = [
|
|
258
|
+
path.join(stateDir, 'clawly') + path.sep,
|
|
259
|
+
path.join(stateDir, 'canvas') + path.sep,
|
|
260
|
+
path.join(stateDir, 'memory') + path.sep,
|
|
261
|
+
]
|
|
262
|
+
if (!allowedPrefixes.some((p) => resolved.startsWith(p))) {
|
|
263
|
+
throw new Error(`${source}: files under the state directory are not allowed`)
|
|
264
|
+
}
|
|
265
|
+
} else if (hasDotFileComponent(resolved)) {
|
|
266
|
+
throw new Error(`${source}: dot files (e.g. .openclaw, .env) are not allowed`)
|
|
267
|
+
}
|
|
268
|
+
if (isExecutableByExt(resolved)) {
|
|
269
|
+
throw new Error(`${source}: executable files are not allowed`)
|
|
270
|
+
}
|
|
271
|
+
const type = await fileTypeFromFile(resolved)
|
|
272
|
+
if (type && isExecutableByMime(type.mime)) {
|
|
273
|
+
throw new Error(`${source}: file is an executable and cannot be sent`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const ext = path.extname(resolved) || (type?.ext ? `.${type.ext}` : '')
|
|
277
|
+
const dest = localPath(resolved, ext, api)
|
|
278
|
+
if (!(await fileExists(dest))) {
|
|
279
|
+
const dir = path.dirname(dest)
|
|
280
|
+
await fsp.mkdir(dir, {recursive: true})
|
|
281
|
+
await fsp.copyFile(resolved, dest)
|
|
282
|
+
}
|
|
283
|
+
return dest
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const resolved = await Promise.all(sources.map(resolveSource))
|
|
288
|
+
const mediaMessage = formatMediaMessage(resolved, sources, caption)
|
|
289
|
+
const sessionKey = await resolveSessionKey('clawly', api)
|
|
290
|
+
await injectAssistantMessage({sessionKey, message: mediaMessage}, api)
|
|
291
|
+
api.logger.info(`${TOOL_NAME}: injected ${resolved.length} file(s) into session`)
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
content: [
|
|
295
|
+
{type: 'text', text: JSON.stringify({sent: true, count: resolved.length, sources})},
|
|
296
|
+
],
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
300
|
+
api.logger.error(`${TOOL_NAME}: ${msg}`)
|
|
301
|
+
return {content: [{type: 'text', text: JSON.stringify({error: msg})}]}
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
|
|
307
|
+
}
|
package/tools/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {registerIsUserOnlineTool} from './clawly-is-user-online'
|
|
|
4
4
|
import {registerMsgBreakTool} from './clawly-msg-break'
|
|
5
5
|
import {registerDeepSearchTool, registerGrokSearchTool, registerSearchTool} from './clawly-search'
|
|
6
6
|
import {registerSendAppPushTool} from './clawly-send-app-push'
|
|
7
|
-
import {
|
|
7
|
+
import {registerSendFileTool} from './clawly-send-file'
|
|
8
8
|
import {registerSendMessageTool} from './clawly-send-message'
|
|
9
9
|
|
|
10
10
|
export function registerTools(api: PluginApi) {
|
|
@@ -15,6 +15,6 @@ export function registerTools(api: PluginApi) {
|
|
|
15
15
|
registerDeepSearchTool(api)
|
|
16
16
|
registerGrokSearchTool(api)
|
|
17
17
|
registerSendAppPushTool(api)
|
|
18
|
-
|
|
18
|
+
registerSendFileTool(api)
|
|
19
19
|
registerSendMessageTool(api)
|
|
20
20
|
}
|
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent tool: clawly_send_image — send an image to the user.
|
|
3
|
-
*
|
|
4
|
-
* Accepts a URL (https/http) or a local file path (or arrays of either).
|
|
5
|
-
* URLs are downloaded and cached locally. Magic-byte validation ensures
|
|
6
|
-
* only real images are served. Injects a MEDIA:<path> assistant message
|
|
7
|
-
* via chat.inject so the mobile client renders inline images.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import crypto from 'node:crypto'
|
|
11
|
-
import fsp from 'node:fs/promises'
|
|
12
|
-
import path from 'node:path'
|
|
13
|
-
|
|
14
|
-
import {fileTypeFromBuffer, fileTypeFromFile} from 'file-type'
|
|
15
|
-
|
|
16
|
-
import {injectAssistantMessage, resolveSessionKey} from '../gateway/inject'
|
|
17
|
-
import type {PluginApi} from '../types'
|
|
18
|
-
|
|
19
|
-
const TOOL_NAME = 'clawly_send_image'
|
|
20
|
-
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB
|
|
21
|
-
|
|
22
|
-
const parameters: Record<string, unknown> = {
|
|
23
|
-
type: 'object',
|
|
24
|
-
required: ['source'],
|
|
25
|
-
properties: {
|
|
26
|
-
source: {
|
|
27
|
-
oneOf: [
|
|
28
|
-
{type: 'string', description: 'Image URL (https://...) or local file path'},
|
|
29
|
-
{
|
|
30
|
-
type: 'array',
|
|
31
|
-
items: {type: 'string'},
|
|
32
|
-
description: 'Array of image URLs or local file paths',
|
|
33
|
-
},
|
|
34
|
-
],
|
|
35
|
-
},
|
|
36
|
-
caption: {type: 'string', description: 'Optional caption text'},
|
|
37
|
-
},
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ── Image MIME allowlist (aligned with expo-image, excluding SVG) ────
|
|
41
|
-
|
|
42
|
-
const IMAGE_MIMES = new Set([
|
|
43
|
-
'image/jpeg',
|
|
44
|
-
'image/png',
|
|
45
|
-
'image/apng',
|
|
46
|
-
'image/gif',
|
|
47
|
-
'image/webp',
|
|
48
|
-
'image/avif',
|
|
49
|
-
'image/heic',
|
|
50
|
-
'image/bmp',
|
|
51
|
-
'image/x-icon',
|
|
52
|
-
])
|
|
53
|
-
|
|
54
|
-
// ── Helpers ──────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
function downloadsDir(api: PluginApi): string {
|
|
57
|
-
return path.join(api.runtime.state.resolveStateDir(), 'clawly', 'downloads')
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function extFromUrl(url: string): string {
|
|
61
|
-
try {
|
|
62
|
-
const pathname = new URL(url).pathname
|
|
63
|
-
const ext = path.extname(pathname).toLowerCase()
|
|
64
|
-
if (
|
|
65
|
-
ext &&
|
|
66
|
-
['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.ico'].includes(ext)
|
|
67
|
-
)
|
|
68
|
-
return ext
|
|
69
|
-
} catch {}
|
|
70
|
-
return '.jpg'
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function localPath(source: string, ext: string, api: PluginApi): string {
|
|
74
|
-
const hash = crypto.createHash('sha256').update(source).digest('hex')
|
|
75
|
-
return path.join(downloadsDir(api), `${hash}${ext}`)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function fileExists(p: string): Promise<boolean> {
|
|
79
|
-
try {
|
|
80
|
-
await fsp.access(p)
|
|
81
|
-
return true
|
|
82
|
-
} catch {
|
|
83
|
-
return false
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function formatMediaMessage(filePaths: string[], caption?: string): string {
|
|
88
|
-
const parts: string[] = []
|
|
89
|
-
if (caption) parts.push(caption)
|
|
90
|
-
for (const p of filePaths) parts.push(`MEDIA:${p}`)
|
|
91
|
-
return parts.join('\n')
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ── Download ─────────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
/** Downloads an image, validates it, and saves with the detected extension. Returns the final path or throws. */
|
|
97
|
-
const DOWNLOAD_TIMEOUT_MS = 30_000
|
|
98
|
-
|
|
99
|
-
async function downloadImage(url: string, api: PluginApi): Promise<string> {
|
|
100
|
-
const dir = downloadsDir(api)
|
|
101
|
-
await fsp.mkdir(dir, {recursive: true})
|
|
102
|
-
|
|
103
|
-
const controller = new AbortController()
|
|
104
|
-
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS)
|
|
105
|
-
try {
|
|
106
|
-
const res = await fetch(url, {redirect: 'follow', signal: controller.signal})
|
|
107
|
-
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`)
|
|
108
|
-
|
|
109
|
-
const contentLength = res.headers.get('content-length')
|
|
110
|
-
if (contentLength && Number(contentLength) > MAX_DOWNLOAD_BYTES) {
|
|
111
|
-
throw new Error(`file too large (${contentLength} bytes, max ${MAX_DOWNLOAD_BYTES})`)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const chunks: Uint8Array[] = []
|
|
115
|
-
let totalSize = 0
|
|
116
|
-
const reader = res.body?.getReader()
|
|
117
|
-
if (!reader) throw new Error('no response body')
|
|
118
|
-
|
|
119
|
-
while (true) {
|
|
120
|
-
const {done, value} = await reader.read()
|
|
121
|
-
if (done) break
|
|
122
|
-
totalSize += value.byteLength
|
|
123
|
-
if (totalSize > MAX_DOWNLOAD_BYTES) {
|
|
124
|
-
reader.cancel()
|
|
125
|
-
throw new Error(`file too large (exceeded ${MAX_DOWNLOAD_BYTES} bytes)`)
|
|
126
|
-
}
|
|
127
|
-
chunks.push(value)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const buffer = Buffer.concat(chunks)
|
|
131
|
-
|
|
132
|
-
const type = await fileTypeFromBuffer(buffer)
|
|
133
|
-
if (!type || !IMAGE_MIMES.has(type.mime)) {
|
|
134
|
-
throw new Error('downloaded file is not a recognized image format')
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const dest = localPath(url, `.${type.ext}`, api)
|
|
138
|
-
await fsp.writeFile(dest, buffer)
|
|
139
|
-
api.logger.info(`${TOOL_NAME}: downloaded ${url} -> ${dest} (${buffer.length} bytes)`)
|
|
140
|
-
return dest
|
|
141
|
-
} finally {
|
|
142
|
-
clearTimeout(timeout)
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ── Tool registration ────────────────────────────────────────────────
|
|
147
|
-
|
|
148
|
-
export function registerSendImageTool(api: PluginApi) {
|
|
149
|
-
api.registerTool({
|
|
150
|
-
name: TOOL_NAME,
|
|
151
|
-
description:
|
|
152
|
-
'Send one or more images to the user. Accepts a single URL/path or an array of URLs/paths. Images are displayed inline in the chat.',
|
|
153
|
-
parameters,
|
|
154
|
-
async execute(_toolCallId, params) {
|
|
155
|
-
// Normalize source to string[]
|
|
156
|
-
let sources: string[]
|
|
157
|
-
if (typeof params.source === 'string') {
|
|
158
|
-
sources = [params.source.trim()]
|
|
159
|
-
} else if (Array.isArray(params.source)) {
|
|
160
|
-
sources = params.source
|
|
161
|
-
.filter((s): s is string => typeof s === 'string')
|
|
162
|
-
.map((s) => s.trim())
|
|
163
|
-
.filter(Boolean)
|
|
164
|
-
} else {
|
|
165
|
-
sources = []
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (sources.length === 0) {
|
|
169
|
-
return {content: [{type: 'text', text: JSON.stringify({error: 'source is required'})}]}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const caption = typeof params.caption === 'string' ? params.caption.trim() : undefined
|
|
173
|
-
|
|
174
|
-
const resolveSource = async (source: string): Promise<string> => {
|
|
175
|
-
if (source.startsWith('https://') || source.startsWith('http://')) {
|
|
176
|
-
// Check cache with URL-derived extension (best guess)
|
|
177
|
-
const guessExt = extFromUrl(source)
|
|
178
|
-
const cached = localPath(source, guessExt, api)
|
|
179
|
-
if (await fileExists(cached)) {
|
|
180
|
-
api.logger.info(`${TOOL_NAME}: serving cached ${cached}`)
|
|
181
|
-
return cached
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return await downloadImage(source, api)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Local file source
|
|
188
|
-
if (!(await fileExists(source))) {
|
|
189
|
-
throw new Error(`${source}: file not found`)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const type = await fileTypeFromFile(source)
|
|
193
|
-
if (!type || !IMAGE_MIMES.has(type.mime)) {
|
|
194
|
-
throw new Error(`${source}: not a recognized image format`)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Copy to downloads dir so the outbound endpoint can serve it
|
|
198
|
-
const dest = localPath(source, `.${type.ext}`, api)
|
|
199
|
-
if (!(await fileExists(dest))) {
|
|
200
|
-
const dir = path.dirname(dest)
|
|
201
|
-
await fsp.mkdir(dir, {recursive: true})
|
|
202
|
-
await fsp.copyFile(source, dest)
|
|
203
|
-
}
|
|
204
|
-
return dest
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
const resolved = await Promise.all(sources.map(resolveSource))
|
|
209
|
-
|
|
210
|
-
// Inject MEDIA: lines as an assistant message so they appear in the chat
|
|
211
|
-
const mediaMessage = formatMediaMessage(resolved, caption)
|
|
212
|
-
const sessionKey = await resolveSessionKey('clawly', api)
|
|
213
|
-
await injectAssistantMessage({sessionKey, message: mediaMessage}, api)
|
|
214
|
-
api.logger.info(`${TOOL_NAME}: injected ${resolved.length} image(s) into session`)
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
content: [{type: 'text', text: JSON.stringify({sent: true, count: resolved.length})}],
|
|
218
|
-
}
|
|
219
|
-
} catch (err) {
|
|
220
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
221
|
-
api.logger.error(`${TOOL_NAME}: ${msg}`)
|
|
222
|
-
return {content: [{type: 'text', text: JSON.stringify({error: msg})}]}
|
|
223
|
-
}
|
|
224
|
-
},
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
|
|
228
|
-
}
|