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
This commit is contained in:
Peter Steinberger
2026-08-12 20:09:32 -07:00
committed by GitHub
parent 495f295f25
commit 132299bcfa
3 changed files with 54 additions and 17 deletions
@@ -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;
@@ -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<string, unknown> };
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<string, unknown> };
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([
+12 -13
View File
@@ -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<string, unknown>): 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);