@2en/clawly-plugins 1.30.0-beta.1 → 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/auto-pair.ts CHANGED
@@ -4,7 +4,7 @@ import type {PluginApi} from './index'
4
4
  // Security note: clientId is self-reported by the connecting client. This is safe
5
5
  // because the gateway enforces Ed25519 signature verification before a pairing
6
6
  // request is created — only clients with valid device identity reach this stage.
7
- const AUTO_APPROVE_CLIENT_IDS = new Set(['openclaw-ios', 'node-host'])
7
+ const AUTO_APPROVE_CLIENT_IDS = new Set(['openclaw-ios', 'openclaw-macos', 'node-host'])
8
8
  const POLL_INTERVAL_MS = 3_000
9
9
 
10
10
  type PendingRequest = {
@@ -94,6 +94,7 @@
94
94
  // and fallback lists.
95
95
  agents: {
96
96
  defaults: {
97
+ thinkingDefault: "off",
97
98
  model: {
98
99
  primary: "clawly-model-gateway/anthropic/claude-sonnet-4.6",
99
100
  },
package/config-setup.ts CHANGED
@@ -250,12 +250,18 @@ export function patchBrowser(config: Record<string, unknown>): boolean {
250
250
  dirty = true
251
251
  }
252
252
 
253
- // owner: grant mobile app owner permissions (required for cron tool etc.)
253
+ // owner: grant mobile/mac app owner permissions (required for cron tool etc.)
254
254
  const ownerAllowFrom = Array.isArray(commands.ownerAllowFrom)
255
255
  ? (commands.ownerAllowFrom as string[])
256
256
  : []
257
- if (!ownerAllowFrom.includes('openclaw-ios')) {
258
- ownerAllowFrom.push('openclaw-ios')
257
+ let ownerDirty = false
258
+ for (const clientId of ['openclaw-ios', 'openclaw-macos']) {
259
+ if (!ownerAllowFrom.includes(clientId)) {
260
+ ownerAllowFrom.push(clientId)
261
+ ownerDirty = true
262
+ }
263
+ }
264
+ if (ownerDirty) {
259
265
  commands.ownerAllowFrom = ownerAllowFrom
260
266
  config.commands = commands
261
267
  dirty = true
@@ -29,6 +29,9 @@ const DANGEROUS_COMMANDS = [
29
29
  // Reminders + calendar (iOS nodes)
30
30
  'reminders.add',
31
31
  'calendar.add',
32
+ // Device permissions (iOS nodes) — not in OpenClaw's iOS defaults
33
+ 'device.permissions',
34
+ 'device.requestPermission',
32
35
  ]
33
36
 
34
37
  export function registerNodeDangerousAllowlist(api: PluginApi) {
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
- * - clawly_send_image — send an image to the user (URL download or local file)
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
- 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.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 {registerSendImageTool} from './clawly-send-image'
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
- registerSendImageTool(api)
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
- }