fix: explain why chat attachments could not be sent (#151913)

This commit is contained in:
Peter Steinberger 2026-09-18 09:11:59 -07:00 • committed by GitHub
parent 4a4462c85b
commit f0a1c4f0ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 86 additions and 24 deletions

View file

@ -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

View file

@ -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: "<!doctype html><h1>Proof</h1>" },
])("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: "<!doctype html><h1>Report</h1>",
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-" },

View file

@ -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<typeof payload> {
const action = payload.dryRun ? "Prepared" : "Sent";
const sink = payload.sourceReplySink ? ` via ${payload.sourceReplySink}` : "";
const cards = readClawHubRecommendations(payload.sourceReply.channelData);

View file

@ -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