diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md
index 1c5293987dae..60ca81b0ac7d 100644
--- a/docs/start/openclaw.md
+++ b/docs/start/openclaw.md
@@ -226,6 +226,11 @@ Local-path behavior follows the same file-read trust model as the agent:
Keep sensitive files outside the agent-readable filesystem, or keep `tools.fs.workspaceOnly: true` for stricter local-path sends.
+When the message tool cannot stage an attachment for the current conversation,
+its error identifies the file and the reported reason, such as a missing file or
+an unsupported local format. Being inside the workspace does not make every
+file type eligible for host-local attachment reads.
+
## Operations checklist
```bash
diff --git a/src/agents/tools/message-tool.internal-source-reply.integration.test.ts b/src/agents/tools/message-tool.internal-source-reply.integration.test.ts
index 73cb6ba9b333..0030192bcd71 100644
--- a/src/agents/tools/message-tool.internal-source-reply.integration.test.ts
+++ b/src/agents/tools/message-tool.internal-source-reply.integration.test.ts
@@ -136,20 +136,23 @@ describe("WebChat message tool internal source reply", () => {
);
});
- it("stages buffer media before acknowledging the current-source send", async () => {
+ it.each([
+ { filename: "proof.txt", contentType: "text/plain", content: "current-source attachment" },
+ { filename: "proof.html", contentType: "text/html", content: "
Proof
" },
+ ])("stages $filename buffer before acknowledging the current-source send", async (fixture) => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "message-tool-source-buffer-" },
async (state) => {
await fs.mkdir(state.workspaceDir, { recursive: true });
const tool = createCurrentSourceMessageTool({ workspaceDir: state.workspaceDir });
- const attachment = Buffer.from("current-source attachment");
+ const attachment = Buffer.from(fixture.content);
const toolResult = await tool.execute("message-buffer-call", {
action: "send",
message: "Attached proof.",
buffer: attachment.toString("base64"),
- filename: "proof.txt",
- contentType: "text/plain",
+ filename: fixture.filename,
+ contentType: fixture.contentType,
});
const sourceReply = extractMessagingToolSourceReplyPayload(toolResult);
@@ -157,8 +160,8 @@ describe("WebChat message tool internal source reply", () => {
expect(sourceReply?.mediaUrls).toHaveLength(1);
expect(sourceReply?.attachments).toEqual([
expect.objectContaining({
- name: "proof.txt",
- mimeType: "text/plain",
+ name: fixture.filename,
+ mimeType: fixture.contentType,
trustedLocalMedia: true,
}),
]);
@@ -169,6 +172,64 @@ describe("WebChat message tool internal source reply", () => {
);
});
+ it.each([
+ {
+ filename: "report.html",
+ content: "Report
",
+ reason: "Rejected by the local attachment allowlist. Send a supported file type.",
+ },
+ {
+ filename: "missing.txt",
+ content: undefined,
+ reason: "File not found. Check the path and try again.",
+ },
+ ])("reports the staging reason for workspace $filename", async (fixture) => {
+ await withOpenClawTestState(
+ { layout: "state-only", prefix: "message-tool-source-error-" },
+ async (state) => {
+ await fs.mkdir(state.workspaceDir, { recursive: true });
+ const media = path.join(state.workspaceDir, fixture.filename);
+ if (fixture.content !== undefined) {
+ await fs.writeFile(media, fixture.content);
+ }
+ const tool = createCurrentSourceMessageTool({ workspaceDir: state.workspaceDir });
+
+ await expect(
+ tool.execute("message-path-error", {
+ action: "send",
+ message: "Private draft that must not enter the error.",
+ attachments: [{ media, type: "file", name: fixture.filename }],
+ final: true,
+ }),
+ ).rejects.toThrow(
+ new Error(
+ `Current-source media could not be staged.\n⚠️ ${fixture.filename}: ${fixture.reason}`,
+ ),
+ );
+ },
+ );
+ });
+
+ it.each(["proof.txt", "proof.md"])("stages the supported workspace file %s", async (filename) => {
+ await withOpenClawTestState(
+ { layout: "state-only", prefix: "message-tool-source-document-" },
+ async (state) => {
+ await fs.mkdir(state.workspaceDir, { recursive: true });
+ const media = path.join(state.workspaceDir, filename);
+ const content = "# Attachment proof\n";
+ await fs.writeFile(media, content);
+ const tool = createCurrentSourceMessageTool({ workspaceDir: state.workspaceDir });
+ const result = await tool.execute("message-document", {
+ action: "send",
+ attachments: [{ media, type: "file", name: filename }],
+ });
+ const reply = extractMessagingToolSourceReplyPayload(result);
+ expect(reply?.mediaUrls).toHaveLength(1);
+ await expect(fs.readFile(reply?.mediaUrls?.[0] as string, "utf8")).resolves.toBe(content);
+ },
+ );
+ });
+
it("uses policy-scoped bridge access for remote-only current-source media", async () => {
await withOpenClawTestState(
{ layout: "state-only", prefix: "message-tool-source-remote-media-" },
diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts
index ea58925752e0..cadb6a779750 100644
--- a/src/infra/outbound/message-action-runner.ts
+++ b/src/infra/outbound/message-action-runner.ts
@@ -8,7 +8,11 @@ import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../../agents/ag
import type { AgentToolResult } from "../../agents/runtime/index.js";
import { readStringArrayParam, readToolStringParam } from "../../agents/tools/common.js";
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
-import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
+import {
+ appendReplyMediaFailures,
+ getReplyPayloadMetadata,
+ type ReplyPayload,
+} from "../../auto-reply/reply-payload.js";
import type { ChannelId, ChannelPlugin } from "../../channels/plugins/types.public.js";
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
@@ -407,8 +411,14 @@ async function handleInternalSourceReplySendAction(
if (
resolveSendableOutboundReplyParts(sourceReplyPayload).mediaUrls.length !== requestedMediaCount
) {
+ const failureMessage = appendReplyMediaFailures(
+ undefined,
+ getReplyPayloadMetadata(sourceReplyPayload)?.assistantMediaFailures ?? [],
+ );
throw new Error(
- "Current-source media could not be staged. Use an accessible URL, a file inside the agent workspace, or the buffer field.",
+ failureMessage
+ ? `Current-source media could not be staged.\n${failureMessage}`
+ : "Current-source media could not be staged. Use an accessible URL, a file inside the agent workspace, or the buffer field.",
);
}
}
@@ -482,21 +492,7 @@ function buildInternalSourceReplyToolResult(payload: {
mediaUrl?: string;
mediaUrls?: string[];
dryRun: boolean;
-}): AgentToolResult<{
- status: string;
- deliveryStatus: string;
- channel: ChannelId;
- target: string;
- sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
- idempotencyKey?: string;
- sourceReplyTranscriptOwner?: true;
- sourceReplySink?: "internal-ui";
- sourceReply: ReplyPayload;
- message?: string;
- mediaUrl?: string;
- mediaUrls?: string[];
- dryRun: boolean;
-}> {
+}): AgentToolResult {
const action = payload.dryRun ? "Prepared" : "Sent";
const sink = payload.sourceReplySink ? ` via ${payload.sourceReplySink}` : "";
const cards = readClawHubRecommendations(payload.sourceReply.channelData);
diff --git a/src/media/web-media.ts b/src/media/web-media.ts
index 78815017523f..3b4342a221d9 100644
--- a/src/media/web-media.ts
+++ b/src/media/web-media.ts
@@ -1130,7 +1130,7 @@ async function loadWebMediaInternal(
? await resolveTrustedGeneratedHostReadHtml(mediaUrl)
: undefined;
if (hostReadDeclaredMime === "text/html" && !htmlTrust) {
- throw new LocalMediaAccessError("path-not-allowed", HOST_READ_DECLARED_TEXT_ERROR);
+ throw new HostReadMediaTypeError(HOST_READ_DECLARED_TEXT_ERROR);
}
// Local path