From 00de585574550c304f1f8f76f6d11646af38d5d0 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 01:40:27 +0000 Subject: [PATCH] fix(gateway): preserve err.stack when chat.send/agent attachment parsing fails (#76351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - Adds stack-preserving gateway error logging for `chat.send` and agent attachment parse/stage failures, focused tests, and an Unreleased changelog entry. - Reproducibility: yes. for the diagnostic gap: current main shows both affected catches returning `String(err ... g `Error` or `MediaOffloadError` values. I did not reproduce the separate iPad/Tailscale RangeError itself. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(gateway): preserve err.stack when chat.send/agent attachment pars… Validation: - ClawSweeper review passed for head 0e9bd18b31d6bdb9d05f7f7dc99b0b23c701f221. - Required merge gates passed before the squash merge. Prepared head SHA: 0e9bd18b31d6bdb9d05f7f7dc99b0b23c701f221 Review: https://github.com/openclaw/openclaw/pull/76351#issuecomment-4365116612 Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: keen0206 <233564226+keen0206@users.noreply.github.com> --- CHANGELOG.md | 1 + src/gateway/server-methods/agent.test.ts | 46 +++++++++++++ src/gateway/server-methods/agent.ts | 32 +++++++++- .../chat.directive-tags.test.ts | 64 ++++++++++++++++++- src/gateway/server-methods/chat.ts | 27 +++++++- 5 files changed, 167 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7b901c90fa..e58206df890 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206. - Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev. - Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79. - Gateway/responses: emit every client tool call from `/v1/responses` JSON and SSE responses when the agent invokes multiple client tools in a single turn, so multi-tool plans, graph orchestration calls, and similar batched flows no longer drop every call but the last. Fixes #52288. Thanks @CharZhou and @bonelli. diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 3e9ee2f5f53..61de662839d 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -1038,6 +1038,52 @@ describe("gateway agent handler", () => { expect(mocks.agentCommand).not.toHaveBeenCalled(); }); + it("logs attachment parse failures with stack details", async () => { + primeMainAgentRun(); + mocks.agentCommand.mockClear(); + const context = makeContext(); + const respond = vi.fn(); + + await invokeAgent( + { + message: "inspect this", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "test-agent-attachment-parse-stack", + attachments: [ + { + type: "file", + mimeType: "image/png", + fileName: "broken.png", + content: "not-base64", + }, + ], + }, + { respond, context, reqId: "agent-attachment-parse-stack", flushDispatch: false }, + ); + + expect(mocks.agentCommand).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("attachment broken.png: invalid base64 content"), + }), + ); + expect(context.logGateway.error).toHaveBeenCalledWith( + "agent attachment parse failed", + expect.objectContaining({ + consoleMessage: expect.stringContaining( + "agent attachment parse failed: Error: attachment broken.png", + ), + error: expect.stringContaining("Error: attachment broken.png: invalid base64 content"), + }), + ); + const logMeta = (context.logGateway.error as unknown as ReturnType).mock + .calls[0]?.[1] as { error?: string } | undefined; + expect(logMeta?.error).toContain("\n at "); + }); + it("keeps model-run gateway prompts undecorated and forwards raw-run flags", async () => { setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z"); primeMainAgentRun({ cfg: mocks.loadConfigReturn }); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 911924d9196..3e254bf11d4 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -41,6 +41,7 @@ import { } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; +import { formatUncaughtError } from "../../infra/errors.js"; import { resolveAgentDeliveryPlan, resolveAgentOutboundTarget, @@ -126,10 +127,38 @@ import { waitForTerminalGatewayDedupe, } from "./agent-wait-dedupe.js"; import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; -import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; +import type { + GatewayRequestContext, + GatewayRequestHandlerOptions, + GatewayRequestHandlers, +} from "./types.js"; const RESET_COMMAND_RE = /^\/(new|reset)(?:\s+([\s\S]*))?$/i; +function formatAttachmentFailureForLog(err: unknown): string { + const primary = formatUncaughtError(err); + const cause = err instanceof Error ? err.cause : undefined; + if (cause === undefined) { + return primary; + } + const causeText = formatUncaughtError(cause); + if (!causeText || causeText === primary) { + return primary; + } + return `${primary}\nCaused by: ${causeText}`; +} + +function logAttachmentFailure( + logGateway: Pick, + label: string, + err: unknown, +): void { + logGateway.error(label, { + error: formatAttachmentFailureForLog(err), + consoleMessage: `${label}: ${formatForLog(err)}`, + }); +} + function resolveSenderIsOwnerFromClient(client: GatewayRequestHandlerOptions["client"]): boolean { const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; return scopes.includes(ADMIN_SCOPE); @@ -632,6 +661,7 @@ export const agentHandlers: GatewayRequestHandlers = { // MediaOffloadError indicates a server-side storage fault (ENOSPC, EPERM, // etc.). Map it to UNAVAILABLE so clients can retry without treating it as // a bad request. All other errors are input-validation failures → 4xx. + logAttachmentFailure(context.logGateway, "agent attachment parse failed", err); const isServerFault = err instanceof MediaOffloadError; respond( false, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 87a2da9484a..fe26ee538f2 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -417,6 +417,7 @@ function createChatContext(): Pick< logGateway: { warn: vi.fn(), debug: vi.fn(), + error: vi.fn(), } as unknown as GatewayRequestContext["logGateway"], }; } @@ -2772,9 +2773,12 @@ describe("chat directive tag stripping for non-streaming final payloads", () => { path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" }, ]; mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" }; - mockState.stageSandboxMediaError = Object.assign(new Error("ENOSPC: no space left on device"), { + const stageError = Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC", }); + stageError.stack = + "Error: ENOSPC: no space left on device\n at stageSandboxMedia (stage-sandbox-media.ts:1:1)"; + mockState.stageSandboxMediaError = stageError; const respond = vi.fn(); const context = createChatContext(); const pdf = Buffer.from("%PDF-1.4\n%µ¶\n1 0 obj\n<<>>\nendobj\n").toString("base64"); @@ -2808,10 +2812,68 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(payload).toBeUndefined(); expect(error?.code).toBe(ErrorCodes.UNAVAILABLE); expect(error?.message ?? String(error)).toMatch(/ENOSPC|non-image attachments/i); + expect(context.logGateway.error).toHaveBeenCalledWith( + "chat.send attachment parse/stage failed", + expect.objectContaining({ + consoleMessage: expect.stringContaining( + "chat.send attachment parse/stage failed: MediaOffloadError", + ), + error: expect.stringContaining( + "Caused by: Error: ENOSPC: no space left on device\n at stageSandboxMedia", + ), + }), + ); // Orphaned media-store files are cleaned up before the 5xx surfaces. expect(mockState.deleteMediaBufferCalls).toEqual([{ id: "saved-media", subdir: "inbound" }]); }); + it("logs chat.send attachment parse failures with stack details", async () => { + createTranscriptFixture("openclaw-chat-send-attachment-parse-stack-"); + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-chat-send-attachment-parse-stack", + message: "inspect this", + requestParams: { + attachments: [ + { + type: "file", + mimeType: "image/png", + fileName: "broken.png", + content: "not-base64", + }, + ], + }, + expectBroadcast: false, + waitFor: "none", + }); + + expect(mockState.lastDispatchCtx).toBeUndefined(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: expect.stringContaining("attachment broken.png: invalid base64 content"), + }), + ); + expect(context.logGateway.error).toHaveBeenCalledWith( + "chat.send attachment parse/stage failed", + expect.objectContaining({ + consoleMessage: expect.stringContaining( + "chat.send attachment parse/stage failed: Error: attachment broken.png", + ), + error: expect.stringContaining("Error: attachment broken.png: invalid base64 content"), + }), + ); + const logMeta = (context.logGateway.error as unknown as ReturnType).mock + .calls[0]?.[1] as { error?: string } | undefined; + expect(logMeta?.error).toContain("\n at "); + }); + it("surfaces partial non-image staging failures as 5xx UNAVAILABLE", async () => { // Regression: stageSandboxMedia keeps unstaged entries as their original // absolute path, so a simple `stagedPaths.length === nonImage.length` diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 662c4fd2367..7ce6daeb332 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -15,7 +15,7 @@ import type { MsgContext, TemplateContext } from "../../auto-reply/templating.js import { extractCanvasFromText } from "../../chat/canvas-render.js"; import { resolveSessionFilePath } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { formatErrorMessage } from "../../infra/errors.js"; +import { formatErrorMessage, formatUncaughtError } from "../../infra/errors.js"; import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; @@ -242,6 +242,30 @@ type ChatSendExplicitOrigin = { messageThreadId?: string; }; +function formatAttachmentFailureForLog(err: unknown): string { + const primary = formatUncaughtError(err); + const cause = err instanceof Error ? err.cause : undefined; + if (cause === undefined) { + return primary; + } + const causeText = formatUncaughtError(cause); + if (!causeText || causeText === primary) { + return primary; + } + return `${primary}\nCaused by: ${causeText}`; +} + +function logAttachmentFailure( + logGateway: Pick, + label: string, + err: unknown, +): void { + logGateway.error(label, { + error: formatAttachmentFailureForLog(err), + consoleMessage: `${label}: ${formatForLog(err)}`, + }); +} + type SideResultPayload = { kind: "btw"; runId: string; @@ -2041,6 +2065,7 @@ export const chatHandlers: GatewayRequestHandlers = { agentId, })); } catch (err) { + logAttachmentFailure(context.logGateway, "chat.send attachment parse/stage failed", err); respond( false, undefined,