mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
3dd803d7e7
commit
d2931ad3d5
5 changed files with 75 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue