From d2931ad3d5bf6936fbdfa5dfc81fb32f875499b2 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 30 Sep 2026 12:10:30 +0200 Subject: [PATCH] fix(coding-agent): validate codemode image() base64 and detect image type image() now rejects malformed base64 and data without a PNG, JPEG, GIF, or WebP signature, and derives the MIME type from the signature. Invalid image blocks were persisted and made every later provider request fail. closes #10215 --- packages/codemode/CHANGELOG.md | 4 ++ .../codemode/src/runtime/prelude-source.ts | 26 ++++++++-- packages/codemode/test/sandbox.test.ts | 51 ++++++++++++++++--- packages/coding-agent/CHANGELOG.md | 1 + .../test/suite/agent-session-codemode.test.ts | 4 +- 5 files changed, 75 insertions(+), 11 deletions(-) diff --git a/packages/codemode/CHANGELOG.md b/packages/codemode/CHANGELOG.md index db99943d2..95a5c2562 100644 --- a/packages/codemode/CHANGELOG.md +++ b/packages/codemode/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `image()` accepting malformed base64 data and unsupported image types. It now throws a `TypeError` unless the data is valid base64 of a PNG, JPEG, GIF, or WebP image, derives the MIME type from the image signature instead of the declared type, and strips line breaks from wrapped base64 ([#10215](https://github.com/earendil-works/pi/issues/10215)). + ## [0.99.1] - 2026-09-29 ## [0.99.0] - 2026-09-29 diff --git a/packages/codemode/src/runtime/prelude-source.ts b/packages/codemode/src/runtime/prelude-source.ts index 7b08b22fe..b0bc197df 100644 --- a/packages/codemode/src/runtime/prelude-source.ts +++ b/packages/codemode/src/runtime/prelude-source.ts @@ -218,10 +218,18 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson } if (typeof value.data !== "string" || value.data === "") throw new TypeErrorCtor("image expected MCP image data"); if (value.data.toLowerCase().startsWith("data:")) return value.data; - const mimeType = typeof value.mimeType === "string" && value.mimeType ? value.mimeType : "application/octet-stream"; - return "data:" + mimeType + ";base64," + value.data; + return "data:;base64," + value.data; } + // Base64 of the signatures of the formats providers accept inline (PNG, JPEG except + // JPEG-LS, GIF, "RIFF....WEBP"). Signatures start at byte 0, so their encodings are prefixes. + const IMAGE_SIGNATURES = [ + ["image/png", /^iVBORw0KGg/], + ["image/jpeg", /^[/]9j[/](?!9)/], + ["image/gif", /^R0lGOD[dl]h/], + ["image/webp", /^UklG.{8}RUJQ/], + ]; + function image(value) { const url = imageUrl(value); if (url === "") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)}); @@ -235,7 +243,19 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson if (scheme !== "data" || comma === -1 || header.slice(1).every((part) => part.toLowerCase() !== "base64")) { throw new TypeErrorCtor("invalid image output. Pass a base64 data URI instead"); } - if (!finished) bridge("output", "image", url.slice(comma + 1), header[0] || "application/octet-stream"); + // Providers reject the whole request on a bad image, and a persisted image block would be + // resent on every later turn. Line breaks from wrapped base64 are dropped. The declared type + // is ignored in favor of the detected one, as providers also reject mismatches. + const data = url.slice(comma + 1).replace(/\\s+/g, ""); + if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) { + throw new TypeErrorCtor("invalid image output. The image data is not valid base64 (truncated or corrupted?)"); + } + const head = data.slice(0, 16); + const signature = IMAGE_SIGNATURES.find(([, pattern]) => pattern.test(head)); + if (!signature) { + throw new TypeErrorCtor("invalid image output. The image data is not a PNG, JPEG, GIF, or WebP image"); + } + if (!finished) bridge("output", "image", data, signature[0]); } function exit() { diff --git a/packages/codemode/test/sandbox.test.ts b/packages/codemode/test/sandbox.test.ts index ed6845b0f..20c965f04 100644 --- a/packages/codemode/test/sandbox.test.ts +++ b/packages/codemode/test/sandbox.test.ts @@ -17,6 +17,12 @@ afterEach(async () => { const echo: CodemodeTool = { name: "echo", execute: (args) => args }; +// Base64 of the leading bytes of each format. image() only inspects the signature. +const PNG = "iVBORw0KGgo="; +const JPEG = "/9j/4A=="; +const GIF = "R0lGODlh"; +const WEBP = "UklGRgAAAABXRUJQ"; + describe("embedded sources", () => { it("parse as JavaScript", () => { expect(() => new vm.Script(PRELUDE_SOURCE, { filename: "prelude.js" })).not.toThrow(); @@ -49,9 +55,11 @@ describe("script execution", () => { text({ json: true }); text(undefined); text(7); - image("data:image/png;base64,AAAA"); - image({ image_url: "data:image/jpeg;base64,BBBB" }); - image({ type: "image", data: "CCCC", mimeType: "image/gif" }); + image("data:image/png;base64,${PNG}"); + image({ image_url: "data:image/jpeg;base64,${JPEG}" }); + image({ type: "image", data: "${GIF}", mimeType: "image/gif" }); + image("data:image/png;base64,${WEBP}"); + image({ type: "image", data: "${PNG}" }); console.error(new Error("bad")); return null; `); @@ -61,9 +69,12 @@ describe("script execution", () => { { type: "text", text: '{"json":true}' }, { type: "text", text: "undefined" }, { type: "text", text: "7" }, - { type: "image", data: "AAAA", mimeType: "image/png" }, - { type: "image", data: "BBBB", mimeType: "image/jpeg" }, - { type: "image", data: "CCCC", mimeType: "image/gif" }, + { type: "image", data: PNG, mimeType: "image/png" }, + { type: "image", data: JPEG, mimeType: "image/jpeg" }, + { type: "image", data: GIF, mimeType: "image/gif" }, + // The MIME type comes from the data, not from the declared type. + { type: "image", data: WEBP, mimeType: "image/webp" }, + { type: "image", data: PNG, mimeType: "image/png" }, ]); expect(result.output.at(-1)).toMatchObject({ type: "text", text: expect.stringMatching(/^Error: bad/) }); }); @@ -82,6 +93,15 @@ describe("script execution", () => { () => image({ type: "text", text: "x" }), () => image({ type: "image", data: "" }), () => image(42), + () => image("data:image/png;base64,AAAA!"), + () => image("data:image/png;base64,AAAAA"), + () => image("data:image/png;base64,AA=A"), + () => image("data:image/png;base64,"), + () => image("data:image/png;base64,AAAA\\n[Output truncated]"), + () => image({ type: "image", data: "AAAA!", mimeType: "image/png" }), + () => image("data:image/png;base64,AAAA"), + () => image("data:image/png;base64,QUJD"), + () => image("data:image/jpeg;base64,/9j/9w=="), ]) { try { run(); errors.push("no error"); } catch (error) { errors.push(error.name + ": " + error.message); } } @@ -99,6 +119,25 @@ describe("script execution", () => { 'TypeError: image only accepts MCP image blocks, got "text"', "TypeError: image expected MCP image data", "TypeError: image expects a non-empty image URL string, an object with image_url, or a raw MCP image block", + ...Array(6).fill( + "TypeError: invalid image output. The image data is not valid base64 (truncated or corrupted?)", + ), + ...Array(3).fill("TypeError: invalid image output. The image data is not a PNG, JPEG, GIF, or WebP image"), + ]); + }); + + // https://github.com/earendil-works/pi/issues/10215 + it("accepts wrapped and large base64 image data", async () => { + const sandbox = createSandbox(); + const large = `iVBORw0KGgoA${"QUJD".repeat(256 * 1024)}`; + const result = await sandbox.execute(` + image("data:image/png;base64,iVBORw0K\\r\\nGgo=\\n"); + image("data:image/png;base64,${large}"); + `); + expect(result.ok).toBe(true); + expect(result.output).toEqual([ + { type: "image", data: PNG, mimeType: "image/png" }, + { type: "image", data: large, mimeType: "image/png" }, ]); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6b7cf1387..0e0c94f5d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed new sessions intermittently ignoring the saved default model, or warning that no models are available, when it belongs to an extension-registered native provider with a stored credential ([#9962](https://github.com/earendil-works/pi/issues/9962)). - Fixed the `/mcp` sign-in URL not being clickable when it wraps across lines, by emitting it as a terminal hyperlink with a `Cmd/Ctrl+click to open` line like `/login` ([#10186](https://github.com/earendil-works/pi/issues/10186)). +- Fixed codemode `image()` accepting malformed base64 data or unsupported image types, which persisted an invalid image block that made every later provider request fail with HTTP 400 ([#10215](https://github.com/earendil-works/pi/issues/10215)). ## [0.99.1] - 2026-09-29 diff --git a/packages/coding-agent/test/suite/agent-session-codemode.test.ts b/packages/coding-agent/test/suite/agent-session-codemode.test.ts index e9a4680c5..28a076380 100644 --- a/packages/coding-agent/test/suite/agent-session-codemode.test.ts +++ b/packages/coding-agent/test/suite/agent-session-codemode.test.ts @@ -417,7 +417,7 @@ describe("codemode options and store", () => { const harness = await setup(); const result = await run( harness, - '// @options: {"max_output_tokens": 10}\nfor (let i = 0; i < 100; i++) text("row " + i);\nimage("data:image/png;base64,AAAA");', + `// @options: {"max_output_tokens": 10}\nfor (let i = 0; i < 100; i++) text("row " + i);\nimage("data:image/png;base64,${TINY_PNG_BASE64}");`, ); const details = result.details as unknown as CodemodeToolDetails; const path = details.fullOutputPath; @@ -431,7 +431,7 @@ describe("codemode options and store", () => { expect(text).not.toContain("row 50\n"); expect(text).toContain(`[Full output: ${path} (read with offset/limit)]`); // Images follow the truncated text. - expect(result.content.at(-1)).toEqual({ type: "image", data: "AAAA", mimeType: "image/png" }); + expect(result.content.at(-1)).toEqual({ type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }); expect(readFileSync(path, "utf8")).toBe(Array.from({ length: 100 }, (_, i) => `row ${i}`).join("\n")); } finally { rmSync(path, { force: true });