diff --git a/services/kiloclaw/plugins/kilo-chat/src/channel.ts b/services/kiloclaw/plugins/kilo-chat/src/channel.ts index 1d80a18564..33173d7729 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/channel.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/channel.ts @@ -182,7 +182,7 @@ const pluginBase = createChannelPluginBase({ '- Kilo Chat uses the shared `message` tool. Prefer `target` for explicit conversation destinations; omit it to act in the current conversation when supported.', '- `send`: pass `message` plus `target`; `conversationId` and `groupId` are accepted compatibility aliases. To send any attachment, pass base64 `buffer`, `filename`, and `contentType`, or pass a local workspace path with `filePath`/`media` and a caption in `message`.', '- For generated text files or arbitrary local file types, prefer `send` with `filePath`/`media`, or `upload-file` with base64 `buffer`. Do not use `upload-file` with a local `filePath` for plain text or unknown file types.', - '- `upload-file`: use for direct file uploads when you already have base64 `buffer`, `filename`, and `contentType`; local `media`/`path`/`filePath` is best for images, audio, video, PDF, and Office documents.', + '- `upload-file`: use for direct file uploads when you already have base64 `buffer`, `filename`, and `contentType`; local `media`/`path`/`filePath` is best for images, audio, video, PDF, and Office documents. Prefer absolute file paths (e.g. `/root/clawd/report.md`).', '- Kilo Chat actions: `channel-list` lists conversations with optional `limit`; `channel-create` creates a conversation with optional `name`.', '- `read`: omit `target` for the current conversation, or pass `target`/`conversationId`; use `limit` and `before` for pagination.', '- `react`: pass `messageId` and the actual emoji in `emoji`; set `remove=true` to remove that emoji. If `messageId` is omitted, the current inbound message is used when available.', diff --git a/services/kiloclaw/plugins/kilo-chat/src/media-delivery.test.ts b/services/kiloclaw/plugins/kilo-chat/src/media-delivery.test.ts index 263ebd8808..286d19816c 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/media-delivery.test.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/media-delivery.test.ts @@ -27,6 +27,83 @@ describe('loadOutboundMedia', () => { } }); + it('resolves relative paths against the agent working dir, not the OpenClaw workspace', async () => { + const agentDir = await mkdtemp(join(tmpdir(), 'kilo-chat-agent-')); + const openclawWorkspace = await mkdtemp(join(tmpdir(), 'kilo-chat-ws-')); + const previousCwd = process.cwd(); + try { + await writeFile(join(agentDir, 'report.md'), '# Weekly Report'); + process.chdir(agentDir); + + const media = await loadOutboundMedia('report.md', { + mediaAccess: { + localRoots: [openclawWorkspace], + workspaceDir: openclawWorkspace, + readFile: async path => Buffer.from(await readFile(path)), + }, + }); + + expect(media.buffer.toString('utf8')).toBe('# Weekly Report'); + expect(media.fileName).toBe('report.md'); + } finally { + process.chdir(previousCwd); + await rm(agentDir, { recursive: true, force: true }); + await rm(openclawWorkspace, { recursive: true, force: true }); + } + }); + + it('resolves relative filenames containing colons against the agent working dir', async () => { + const agentDir = await mkdtemp(join(tmpdir(), 'kilo-chat-agent-')); + const openclawWorkspace = await mkdtemp(join(tmpdir(), 'kilo-chat-ws-')); + const previousCwd = process.cwd(); + try { + await writeFile(join(agentDir, 'report:2026-07-03.md'), '# Weekly Report'); + process.chdir(agentDir); + + const media = await loadOutboundMedia('report:2026-07-03.md', { + mediaAccess: { + localRoots: [openclawWorkspace], + workspaceDir: openclawWorkspace, + readFile: async path => Buffer.from(await readFile(path)), + }, + }); + + expect(media.buffer.toString('utf8')).toBe('# Weekly Report'); + expect(media.fileName).toBe('report:2026-07-03.md'); + } finally { + process.chdir(previousCwd); + await rm(agentDir, { recursive: true, force: true }); + await rm(openclawWorkspace, { recursive: true, force: true }); + } + }); + + it('allows files under the agent working dir even when it is outside the configured local roots', async () => { + const agentDir = await mkdtemp(join(tmpdir(), 'kilo-chat-agent-')); + const openclawWorkspace = await mkdtemp(join(tmpdir(), 'kilo-chat-ws-')); + const previousCwd = process.cwd(); + try { + const filePath = join(agentDir, 'report.md'); + await writeFile(filePath, '# Weekly Report'); + process.chdir(agentDir); + + // No readFile → exercises the real OpenClaw loader and its local-roots gating. + const media = await loadOutboundMedia(filePath, { + mediaAccess: { + localRoots: [openclawWorkspace], + workspaceDir: openclawWorkspace, + }, + }); + + expect(media.buffer.toString('utf8')).toBe('# Weekly Report'); + expect(media.contentType).toBe('text/markdown'); + expect(media.fileName).toBe('report.md'); + } finally { + process.chdir(previousCwd); + await rm(agentDir, { recursive: true, force: true }); + await rm(openclawWorkspace, { recursive: true, force: true }); + } + }); + it('passes custom mediaReadFile through to the OpenClaw media loader', async () => { const media = await loadOutboundMedia('/virtual/generated.txt', { mediaLocalRoots: 'any', diff --git a/services/kiloclaw/plugins/kilo-chat/src/media-delivery.ts b/services/kiloclaw/plugins/kilo-chat/src/media-delivery.ts index f2d261090f..ba979f4694 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/media-delivery.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/media-delivery.ts @@ -2,6 +2,7 @@ import { loadOutboundMediaFromUrl, type OutboundMediaLoadOptions, } from 'openclaw/plugin-sdk/outbound-media'; +import { existsSync } from 'node:fs'; import { basename, isAbsolute, resolve } from 'node:path'; import type { ContentBlock, KiloChatClient } from './client.js'; import { ATTACHMENT_MAX_BYTES } from './synced/schemas.js'; @@ -90,10 +91,31 @@ function resolveFilename(contentType: string | undefined, suggested: string | un return 'file.bin'; } +// The kiloclaw agent works in the process cwd (/root/clawd), but OpenClaw's +// media workspace defaults to ~/.openclaw/workspace — a directory the agent +// never writes to. Resolve agent-relative paths against cwd first and include +// cwd in the allowed local roots, so files the agent generates are attachable. +function agentWorkingDir(): string { + return process.cwd(); +} + +function hasUriScheme(raw: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(raw.trim()); +} + +function withAgentDirRoot( + roots: OutboundMediaLoadOptions['mediaLocalRoots'] +): readonly string[] | 'any' { + if (roots === 'any') return roots; + return [...(roots ?? []), agentWorkingDir()]; +} + function resolveLocalMediaPath(mediaUrl: string, context: OutboundMediaLoadContext): string { + if (isAbsolute(mediaUrl) || hasUriScheme(mediaUrl)) return mediaUrl; + const cwdPath = resolve(agentWorkingDir(), mediaUrl); + if (existsSync(cwdPath)) return cwdPath; const workspaceDir = context.mediaAccess?.workspaceDir; - if (workspaceDir && !isAbsolute(mediaUrl)) return resolve(workspaceDir, mediaUrl); - return mediaUrl; + return workspaceDir ? resolve(workspaceDir, mediaUrl) : cwdPath; } function inferMimeFromFilename(fileName: string | undefined): string | undefined { @@ -118,10 +140,14 @@ export async function loadOutboundMedia( } const channelMediaAccess = mediaAccessForChannelRead(context.mediaAccess); - const loaded = await loadOutboundMediaFromUrl(mediaUrl, { + const localRoots = withAgentDirRoot(context.mediaLocalRoots ?? channelMediaAccess?.localRoots); + const loaded = await loadOutboundMediaFromUrl(resolveLocalMediaPath(mediaUrl, context), { maxBytes: ATTACHMENT_MAX_BYTES, - mediaAccess: channelMediaAccess, - mediaLocalRoots: context.mediaLocalRoots ?? channelMediaAccess?.localRoots, + mediaAccess: channelMediaAccess && { + ...channelMediaAccess, + ...(localRoots === 'any' ? {} : { localRoots: [...localRoots] }), + }, + mediaLocalRoots: localRoots, }); return { buffer: Buffer.isBuffer(loaded.buffer) ? loaded.buffer : Buffer.from(loaded.buffer),