diff --git a/src/gateway/server-methods/artifacts.test.ts b/src/gateway/server-methods/artifacts.test.ts index 373d5d04a778..62b6cef8f21a 100644 --- a/src/gateway/server-methods/artifacts.test.ts +++ b/src/gateway/server-methods/artifacts.test.ts @@ -756,6 +756,127 @@ describe("artifacts RPC handlers", () => { expect(artifacts?.[0]).not.toHaveProperty("data"); }); + it("treats malformed direct artifact data as unsupported downloads", async () => { + mockedMessages([ + { + role: "assistant", + content: [ + { + type: "file", + data: "not-base64!", + title: "bad.txt", + }, + ], + __openclaw: { seq: 6 }, + }, + ]); + + const { calls } = await listArtifacts({ sessionKey: "agent:main:main" }); + const artifacts = expectArtifactList(calls).artifacts; + expect(artifacts).toHaveLength(1); + expectFields(artifacts?.[0], { + title: "bad.txt", + }); + expectFields(artifacts?.[0]?.download, { mode: "unsupported" }); + expect(artifacts?.[0]).not.toHaveProperty("data"); + }); + + it("keeps unpadded direct artifact base64 downloadable", async () => { + mockedMessages([ + { + role: "assistant", + content: [ + { + type: "file", + data: "JVBERi0", + title: "report.pdf", + }, + ], + __openclaw: { seq: 7 }, + }, + ]); + + const listed = await listArtifacts({ sessionKey: "agent:main:main" }); + const artifact = expectArtifactList(listed.calls).artifacts?.[0]; + const artifactId = requireNonEmptyString(artifact?.id, "expected listed artifact id"); + expectFields(artifact, { + title: "report.pdf", + sizeBytes: 5, + }); + expectFields(artifact?.download, { mode: "bytes" }); + + const download = await downloadArtifact({ + sessionKey: "agent:main:main", + artifactId, + }); + const downloadPayload = expectOkPayload(download.calls) as Record; + expectFields(downloadPayload, { + encoding: "base64", + data: "JVBERi0=", + }); + }); + + it("treats malformed base64 data URLs as unsupported downloads", async () => { + mockedMessages([ + { + role: "assistant", + content: [ + { + type: "image", + image_url: "data:image/png;base64,not-base64!", + alt: "bad.png", + }, + ], + __openclaw: { seq: 7 }, + }, + ]); + + const { calls } = await listArtifacts({ sessionKey: "agent:main:main" }); + const artifacts = expectArtifactList(calls).artifacts; + expect(artifacts).toHaveLength(1); + expectFields(artifacts?.[0], { + title: "bad.png", + }); + expectFields(artifacts?.[0]?.download, { mode: "unsupported" }); + expect(artifacts?.[0]).not.toHaveProperty("data"); + }); + + it("keeps unpadded base64 data URLs downloadable", async () => { + mockedMessages([ + { + role: "assistant", + content: [ + { + type: "image", + image_url: "data:image/gif;base64,R0lGOD", + alt: "tiny.gif", + }, + ], + __openclaw: { seq: 8 }, + }, + ]); + + const listed = await listArtifacts({ sessionKey: "agent:main:main" }); + const artifact = expectArtifactList(listed.calls).artifacts?.[0]; + const artifactId = requireNonEmptyString(artifact?.id, "expected listed artifact id"); + expectFields(artifact, { + title: "tiny.gif", + mimeType: "image/gif", + sizeBytes: 4, + }); + expectFields(artifact?.download, { mode: "bytes" }); + + const download = await downloadArtifact({ + sessionKey: "agent:main:main", + artifactId, + }); + const downloadPayload = expectOkPayload(download.calls) as Record; + expectFields(downloadPayload, { + encoding: "base64", + data: "R0lGOD==", + }); + }); + it("treats unsafe artifact URLs as unsupported downloads", async () => { mockedMessages([ { diff --git a/src/gateway/server-methods/artifacts.ts b/src/gateway/server-methods/artifacts.ts index c7875ca41a09..030ba4df99d1 100644 --- a/src/gateway/server-methods/artifacts.ts +++ b/src/gateway/server-methods/artifacts.ts @@ -51,6 +51,11 @@ type ArtifactCollectionOptions = { downloadArtifactId?: string; }; +type ArtifactBase64Payload = { + data?: string; + sizeBytes: number; +}; + type ResolvedArtifactSession = { sessionKey: string; agentId?: string; @@ -155,40 +160,85 @@ function base64FromDataUrl(value: string): string | undefined { if (!metadata.includes(";base64")) { return undefined; } - return trimmed.slice(commaIndex + 1).replace(/\s+/g, ""); + return trimmed.slice(commaIndex + 1); } function isBase64Whitespace(value: string): boolean { return value === " " || value === "\n" || value === "\r" || value === "\t"; } -function estimateBase64Size(value: string | undefined): number | undefined { +function isArtifactBase64DataChar(value: string): boolean { + const code = value.charCodeAt(0); + return ( + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) || + (code >= 0x30 && code <= 0x39) || + value === "+" || + value === "/" || + value === "-" || + value === "_" + ); +} + +function normalizeArtifactBase64Char(value: string): string { + if (value === "-") { + return "+"; + } + if (value === "_") { + return "/"; + } + return value; +} + +function readArtifactBase64Payload( + value: string | undefined, + opts: { includeData: boolean }, +): ArtifactBase64Payload | undefined { if (!value) { return undefined; } let encodedLength = 0; let padding = 0; + let sawPadding = false; + let data = opts.includeData ? "" : undefined; for (const char of value) { - if (!char || isBase64Whitespace(char)) { - continue; - } - encodedLength += 1; - } - for (let index = value.length - 1; index >= 0 && padding < 2; index -= 1) { - const char = value[index]; - if (!char || isBase64Whitespace(char)) { + if (isBase64Whitespace(char)) { continue; } if (char === "=") { padding += 1; + if (padding > 2) { + return undefined; + } + sawPadding = true; + encodedLength += 1; + if (data !== undefined) { + data += char; + } continue; } - break; + if (sawPadding || !isArtifactBase64DataChar(char)) { + return undefined; + } + encodedLength += 1; + if (data !== undefined) { + data += normalizeArtifactBase64Char(char); + } } if (encodedLength === 0) { return undefined; } - return Math.max(0, Math.floor((encodedLength * 3) / 4) - padding); + const remainder = encodedLength % 4; + if ((padding > 0 && remainder !== 0) || remainder === 1) { + return undefined; + } + if (data !== undefined && padding === 0 && remainder > 0) { + data += "=".repeat(4 - remainder); + } + return { + ...(data !== undefined ? { data } : {}), + sizeBytes: Math.max(0, Math.floor((encodedLength * 3) / 4) - padding), + }; } function mediaUrlValue(value: unknown): string | undefined { @@ -274,10 +324,14 @@ function resolveBlockDownload( const dataUrl = [url, sourceUrl, imageUrl, audioUrl, data, content, sourceData].find( (value) => typeof value === "string" && /^data:/i.test(value), ); - const base64FromDetectedDataUrl = dataUrl ? base64FromDataUrl(dataUrl) : undefined; - const directBase64 = [data, sourceData, content].find( - (value) => typeof value === "string" && !/^data:/i.test(value), + const base64FromDetectedDataUrl = readArtifactBase64Payload( + dataUrl ? base64FromDataUrl(dataUrl) : undefined, + opts, ); + const directBase64 = [data, sourceData, content] + .filter((value): value is string => typeof value === "string" && !/^data:/i.test(value)) + .map((value) => readArtifactBase64Payload(value, opts)) + .find((value): value is ArtifactBase64Payload => value !== undefined); const base64 = base64FromDetectedDataUrl ?? directBase64; const remoteUrl = [url, sourceUrl, imageUrl, audioUrl].find( (value) => typeof value === "string" && isSafeDownloadUrl(value), @@ -292,9 +346,14 @@ function resolveBlockDownload( const sizeBytes = typeof explicitSize === "number" && Number.isFinite(explicitSize) && explicitSize >= 0 ? Math.floor(explicitSize) - : estimateBase64Size(base64); + : base64?.sizeBytes; if (base64) { - return { mode: "bytes", ...(opts.includeData ? { data: base64 } : {}), mimeType, sizeBytes }; + return { + mode: "bytes", + ...(base64.data ? { data: base64.data } : {}), + mimeType, + sizeBytes, + }; } if (remoteUrl) { return { mode: "url", url: remoteUrl, mimeType, sizeBytes };