diff --git a/extensions/openai/image-generation-provider.test.ts b/extensions/openai/image-generation-provider.test.ts index afaa469b388..66144ceba31 100644 --- a/extensions/openai/image-generation-provider.test.ts +++ b/extensions/openai/image-generation-provider.test.ts @@ -1629,6 +1629,79 @@ describe("openai image generation provider", () => { }); }); + it.each([ + { + name: "invalid alphabet from output item", + event: { + type: "response.output_item.done", + item: { type: "image_generation_call", result: "aGVs!bG8=" }, + }, + }, + { + name: "invalid alphabet from completed output", + event: { + type: "response.completed", + response: { + output: [{ type: "image_generation_call", result: "aGVs!bG8=" }], + }, + }, + }, + { + name: "missing canonical padding", + event: { + type: "response.output_item.done", + item: { type: "image_generation_call", result: "aGVsbG8" }, + }, + }, + { + name: "internal whitespace", + event: { + type: "response.output_item.done", + item: { type: "image_generation_call", result: "aGVs bG8=" }, + }, + }, + { + name: "non-zero trailing bits", + event: { + type: "response.output_item.done", + item: { type: "image_generation_call", result: "Zh==" }, + }, + }, + ])("rejects malformed Codex image base64 from $name events", async ({ event }) => { + mockCodexAuthOnly(); + mockCodexRawStream(`data: ${JSON.stringify(event)}\n\n`); + + const provider = buildOpenAIImageGenerationProvider(); + await expect( + provider.generateImage({ + provider: "openai", + model: "gpt-image-2", + prompt: "Draw from malformed image data", + cfg: {}, + }), + ).rejects.toThrow("OpenAI Codex image generation returned malformed base64 image data"); + }); + + it("accepts Codex-compatible surrounding Unicode whitespace", async () => { + mockCodexAuthOnly(); + mockCodexRawStream( + `data: ${JSON.stringify({ + type: "response.output_item.done", + item: { type: "image_generation_call", result: "\u0085aGVsbG8=\u0085" }, + })}\n\n`, + ); + + const provider = buildOpenAIImageGenerationProvider(); + const result = await provider.generateImage({ + provider: "openai", + model: "gpt-image-2", + prompt: "Draw from valid image data", + cfg: {}, + }); + + expect(result.images[0]?.buffer).toEqual(Buffer.from("hello")); + }); + it("honors configured Codex transport overrides for OAuth image generation", async () => { mockCodexAuthOnly(); mockCodexImageStream({ imageData: "codex-image" }); diff --git a/extensions/openai/image-generation-provider.ts b/extensions/openai/image-generation-provider.ts index e86b2b0e785..33869a0b6ea 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -14,7 +14,7 @@ import { import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; import { resolveClosestSize } from "openclaw/plugin-sdk/media-generation-runtime"; import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; -import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime"; +import { canonicalizeBase64, MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime"; import { ensureAuthProfileStore, hasConfiguredSecretInput, @@ -66,6 +66,7 @@ const OPENAI_MAX_IMAGE_RESULTS = 4; const MAX_CODEX_IMAGE_SSE_BYTES = 64 * 1024 * 1024; const MAX_CODEX_IMAGE_SSE_EVENTS = 512; const MAX_CODEX_IMAGE_BASE64_CHARS = 64 * 1024 * 1024; +const STANDARD_BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const LOG_VALUE_MAX_CHARS = 256; const MOCK_OPENAI_PROVIDER_ID = "mock-openai"; const OPENAI_OUTPUT_FORMATS = ["png", "jpeg", "webp"] as const; @@ -577,7 +578,22 @@ function decodeCodexImagePayload(payload: string): Buffer { if (payload.length > MAX_CODEX_IMAGE_BASE64_CHARS) { throw new Error("OpenAI Codex image generation result exceeded size limit"); } - return Buffer.from(payload, "base64"); + // Rust's str::trim follows Unicode White_Space (including U+0085), while + // JavaScript's trim does not. Match Codex before enforcing canonical Base64. + const trimmedPayload = payload.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ""); + const canonicalPayload = canonicalizeBase64(trimmedPayload); + const padding = canonicalPayload?.endsWith("==") ? 2 : canonicalPayload?.endsWith("=") ? 1 : 0; + const trailingBitsMask = padding === 2 ? 0x0f : padding === 1 ? 0x03 : 0; + const trailingValue = + padding > 0 ? STANDARD_BASE64_ALPHABET.indexOf(canonicalPayload?.at(-(padding + 1)) ?? "") : 0; + if ( + !canonicalPayload || + canonicalPayload !== trimmedPayload || + (trailingValue & trailingBitsMask) !== 0 + ) { + throw new Error("OpenAI Codex image generation returned malformed base64 image data"); + } + return Buffer.from(canonicalPayload, "base64"); } function toCodexImage(