Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion services/kiloclaw/plugins/kilo-chat/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
77 changes: 77 additions & 0 deletions services/kiloclaw/plugins/kilo-chat/src/media-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
36 changes: 31 additions & 5 deletions services/kiloclaw/plugins/kilo-chat/src/media-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
Expand Down