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