Skip to content
Merged
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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low, Functional: hasUriScheme requiring :// treats scheme-only URIs like data: as relative filesystem paths.

Nice tightening here. Requiring :// cleanly fixes the earlier problem where a filename such as report:2026-07-03.md was misread as a URI, and the regression test locks that in.

One edge worth a thought: some legitimate URIs are schemed but have no authority component, so no //. The clearest example is a data: URI (data:text/plain;base64,...), and mailto:/blob: are similar. With this regex those no longer short-circuit in resolveLocalMediaPath, so they fall through to resolve(agentWorkingDir(), mediaUrl) and then the workspaceDir fallback, which turns the value into a bogus path. Because non-http values skip the isHttpUrl early return in sendKiloChatMediaMessage and reach this loader, a data: URL passed as filePath/media would fail the lookup with a confusing "not found under workspace" style error rather than being handled as inline data.

In practice this is low risk: the outbound flow uses base64 buffer for direct uploads and local paths for send, so a bare data: URL is an unlikely input, which is why this is not blocking.

Suggestion: if data: URIs are ever a possible mediaUrl, match against an explicit allowlist of real schemes (http:, https:, data:, file:, blob:) instead of requiring ://. If they are not supported anywhere upstream, a one-line comment noting that only authority-based URLs are intentionally treated as pre-resolved would document the deliberate narrowing.

}

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