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
This commit is contained in:
Armin Ronacher 2026-09-30 12:10:30 +02:00
parent 3dd803d7e7
commit d2931ad3d5
5 changed files with 75 additions and 11 deletions

View file

@ -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

View file

@ -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() {

View file

@ -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" },
]);
});

View file

@ -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

View file

@ -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 });