From 132299bcfa7187c59d8c113f2a60401da2110b11 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 20:09:32 -0700 Subject: [PATCH] fix(gateway): allow downloading zero-byte artifacts (#122928) * fix(gateway): preserve zero-byte artifacts * fix(gateway): keep non-string data out of artifact lists * test(gateway): compact zero-byte artifact coverage --- .../server-methods/artifacts-base64.ts | 5 +-- src/gateway/server-methods/artifacts.test.ts | 41 +++++++++++++++++++ src/gateway/server-methods/artifacts.ts | 25 ++++++----- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/gateway/server-methods/artifacts-base64.ts b/src/gateway/server-methods/artifacts-base64.ts index 176a9d398a83..be4cc9bb9b2a 100644 --- a/src/gateway/server-methods/artifacts-base64.ts +++ b/src/gateway/server-methods/artifacts-base64.ts @@ -52,7 +52,7 @@ export function readArtifactBase64Payload( value: string | undefined, opts: { includeData: boolean }, ): ArtifactBase64Payload | undefined { - if (!value) { + if (value === undefined) { return undefined; } let encodedLength = 0; @@ -83,9 +83,6 @@ export function readArtifactBase64Payload( data += normalizeArtifactBase64Char(char); } } - if (encodedLength === 0) { - return undefined; - } const remainder = encodedLength % 4; if ((padding > 0 && remainder !== 0) || remainder === 1) { return undefined; diff --git a/src/gateway/server-methods/artifacts.test.ts b/src/gateway/server-methods/artifacts.test.ts index 5a63ed74c195..ea2450e69fab 100644 --- a/src/gateway/server-methods/artifacts.test.ts +++ b/src/gateway/server-methods/artifacts.test.ts @@ -409,6 +409,47 @@ describe("artifacts RPC handlers", () => { expectFields(downloadPayload.artifact, { id: artifactId }); }); + it.each([ + { type: "file", data: "", sizeBytes: 0, title: "direct.bin" }, + { + type: "file", + source: { data: "", media_type: "application/octet-stream", sizeBytes: 0 }, + title: "source.bin", + }, + { + type: "file", + data: " data:application/octet-stream;base64, ", + sizeBytes: 0, + title: "data-url.bin", + }, + { data: "", sizeBytes: 0, title: "untyped.bin" }, + ])("lists, gets, and downloads the zero-byte $title artifact", async (block) => { + mockedMessages([{ role: "assistant", content: [block], __openclaw: { seq: 2 } }]); + const artifact = expectFirstArtifact( + (await listArtifacts({ sessionKey: "agent:main:main" })).calls, + ); + const artifactId = requireNonEmptyString(artifact?.id, "expected zero-byte artifact id"); + const expected = { id: artifactId, sizeBytes: 0, download: { mode: "bytes" } }; + expect(artifact).toMatchObject(expected); + expect(artifact).not.toHaveProperty("data"); + const get = await getArtifact({ sessionKey: "agent:main:main", artifactId }); + const getPayload = expectOkPayload(get.calls) as { artifact?: Record }; + expect(getPayload.artifact).toMatchObject(expected); + expect(getPayload.artifact).not.toHaveProperty("data"); + const download = await downloadArtifact({ sessionKey: "agent:main:main", artifactId }); + const payload = expectOkPayload(download.calls) as { artifact?: Record }; + expectFields(payload, { encoding: "base64", data: "" }); + expect(payload.artifact).toMatchObject(expected); + }); + it.each([null, 0, false, {}])( + "does not discover untyped non-string data as an artifact: %j", + async (data) => { + mockedMessages([{ role: "assistant", content: [{ data }], __openclaw: { seq: 2 } }]); + const listed = await listArtifacts({ sessionKey: "agent:main:main" }); + expect(expectArtifactList(listed.calls)).toEqual({ artifacts: [] }); + }, + ); + it("preserves managed artifact identity and returns a ticketed download URL", async () => { const artifactId = "artifact_managed_image_11111111-1111-4111-8111-111111111111"; mockedMessages([ diff --git a/src/gateway/server-methods/artifacts.ts b/src/gateway/server-methods/artifacts.ts index 387a7df99948..de83204c8c5a 100644 --- a/src/gateway/server-methods/artifacts.ts +++ b/src/gateway/server-methods/artifacts.ts @@ -3,7 +3,10 @@ import { createHash } from "node:crypto"; import { isHttpUrl } from "@openclaw/net-policy/url-protocol"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; -import { normalizeOptionalString as asNonEmptyString } from "@openclaw/normalization-core/string-coerce"; +import { + normalizeOptionalString as asNonEmptyString, + readStringValue, +} from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape, @@ -247,13 +250,13 @@ function resolveBlockDownload( mimeType?: string; sizeBytes?: number; } { - const data = asNonEmptyString(block.data); - const content = asNonEmptyString(block.content); + const data = readStringValue(block.data)?.trim(); + const content = readStringValue(block.content)?.trim(); const url = asNonEmptyString(block.url) ?? asNonEmptyString(block.openUrl); const imageUrl = mediaUrlValue(block.image_url); const audioUrl = asNonEmptyString(block.audio_url); const source = asOptionalRecord(block.source); - const sourceData = asNonEmptyString(source?.data); + const sourceData = readStringValue(source?.data)?.trim(); const sourceUrl = asNonEmptyString(source?.url); const dataUrl = [url, sourceUrl, imageUrl, audioUrl, data, content, sourceData].find( (value) => typeof value === "string" && /^data:/i.test(value), @@ -282,12 +285,7 @@ function resolveBlockDownload( ? Math.floor(explicitSize) : base64?.sizeBytes; if (base64) { - return { - mode: "bytes", - ...(base64.data ? { data: base64.data } : {}), - mimeType, - sizeBytes, - }; + return { mode: "bytes", data: base64.data, mimeType, sizeBytes }; } if (remoteUrl) { return { mode: "url", url: remoteUrl, mimeType, sizeBytes }; @@ -310,8 +308,9 @@ function isArtifactBlock(block: Record): boolean { ) { return true; } - return Boolean( - block.url || block.openUrl || block.data || block.source || block.image_url || block.audio_url, + return ( + typeof block.data === "string" || + Boolean(block.url || block.openUrl || block.source || block.image_url || block.audio_url) ); } @@ -378,7 +377,7 @@ function collectArtifactsFromMessage(params: { messageSeq, source: "session-transcript", download: { mode: download.mode }, - ...(download.data ? { data: download.data } : {}), + ...(download.data !== undefined ? { data: download.data } : {}), ...(download.url ? { url: download.url } : {}), }; params.artifacts.push(summary);