From 319fecb89b17b7bf8a4b62734de9a6cc6ceabe49 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 2 Oct 2026 17:53:52 +0200 Subject: [PATCH] fix(codemode): cap script output so print loops cannot exhaust host memory text(), image(), and console.* fail the script with a RangeError past MAX_OUTPUT_CHARS (16 Mi) characters or MAX_OUTPUT_ITEMS (100000) items. closes #10283 --- packages/codemode/CHANGELOG.md | 4 +++ packages/codemode/README.md | 2 +- packages/codemode/src/index.ts | 7 +++- .../codemode/src/runtime/prelude-source.ts | 35 +++++++++++++++++-- packages/codemode/test/sandbox.test.ts | 28 ++++++++++++++- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/codemode.md | 2 +- 7 files changed, 72 insertions(+), 7 deletions(-) diff --git a/packages/codemode/CHANGELOG.md b/packages/codemode/CHANGELOG.md index b5e691e8e..8e909079d 100644 --- a/packages/codemode/CHANGELOG.md +++ b/packages/codemode/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed scripts that print in a loop growing the host's memory until it crashes: output is limited to `MAX_OUTPUT_CHARS` (16 Mi) characters and `MAX_OUTPUT_ITEMS` (100000) items, and a script past either limit fails with a `RangeError` ([#10283](https://github.com/earendil-works/pi/issues/10283)) + ## [1.0.0] - 2026-10-01 ### Added diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 2fc4db361..d2878650e 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -171,7 +171,7 @@ const codemodeTool: AgentTool = { | `aborted` | `options.signal` fired or `close()` was called; the worker was terminated | | `sandbox` | the worker or VM failed, for example a wasm trap or a missing worker file | -`result.output` holds the text and image items in the order the script produced them, also for failed executions. `result.calls` lists every tool call with `status: "ok" | "error" | "cancelled"`. A call that is still running when the script returns (not awaited) is aborted through the tool's `signal` and reported as `cancelled`. +`result.output` holds the text and image items in the order the script produced them, also for failed executions. The host keeps all of it until the script ends, so output is limited to `MAX_OUTPUT_CHARS` (16 Mi) characters of text and base64 image data and `MAX_OUTPUT_ITEMS` (100000) items. Past either limit the script fails with a `RangeError`, even if it catches the error. `result.calls` lists every tool call with `status: "ok" | "error" | "cancelled"`. A call that is still running when the script returns (not awaited) is aborted through the tool's `signal` and reported as `cancelled`. ## How it works diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts index e6acd3e11..d223fa5c1 100644 --- a/packages/codemode/src/index.ts +++ b/packages/codemode/src/index.ts @@ -11,7 +11,12 @@ export { } from "./declarations.ts"; export { toCodemodeIdentifier } from "./identifier.ts"; export { CodemodeSandbox } from "./runtime/host.ts"; -export { MAX_STORE_TOTAL_CHARS, MAX_STORE_VALUE_CHARS } from "./runtime/prelude-source.ts"; +export { + MAX_OUTPUT_CHARS, + MAX_OUTPUT_ITEMS, + MAX_STORE_TOTAL_CHARS, + MAX_STORE_VALUE_CHARS, +} from "./runtime/prelude-source.ts"; export { CODEMODE_OPTIONS_PREFIX, CODEMODE_SOURCE_GRAMMAR, diff --git a/packages/codemode/src/runtime/prelude-source.ts b/packages/codemode/src/runtime/prelude-source.ts index 7333cb849..62eddec56 100644 --- a/packages/codemode/src/runtime/prelude-source.ts +++ b/packages/codemode/src/runtime/prelude-source.ts @@ -27,6 +27,14 @@ */ export const MAX_STORE_VALUE_CHARS = 256 * 1024; export const MAX_STORE_TOTAL_CHARS = 1024 * 1024; +/** + * Output one script may produce with `text()`, `image()`, and `console.*`: characters of text and + * base64 image data, and items. The host keeps all output until the script ends, so without a + * limit a script that prints in a loop grows the host's memory until it crashes. The item limit + * covers loops that print empty strings. + */ +export const MAX_OUTPUT_CHARS = 16 * 1024 * 1024; +export const MAX_OUTPUT_ITEMS = 100_000; const IMAGE_HELPER_EXPECTS = "image expects a non-empty image URL string, an object with image_url, or a raw MCP image block"; @@ -38,6 +46,7 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson const promiseThen = Promise.prototype.then; const ErrorCtor = Error; const TypeErrorCtor = TypeError; + const RangeErrorCtor = RangeError; const pending = new Map(); let nextId = 1; let finished = false; @@ -223,6 +232,26 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson Object.defineProperty(globalThis, "store", { value: store, enumerable: true }); Object.defineProperty(globalThis, "load", { value: load, enumerable: true }); + let outputChars = 0; + let outputItems = 0; + + // Past the output limits the script fails: done() reports the error, so catching it does not + // resume output, and the host ends the script. + function output(kind, data, mimeType) { + if (finished) return; + outputChars += data.length; + outputItems++; + if (outputChars > ${MAX_OUTPUT_CHARS} || outputItems > ${MAX_OUTPUT_ITEMS}) { + const error = new RangeErrorCtor( + "script output exceeded the limit of ${MAX_OUTPUT_CHARS} characters or ${MAX_OUTPUT_ITEMS} text(), image(), and console calls. " + + "Print a summary instead, or write large data to a file with a tool.", + ); + done(false, describeError(error)); + throw error; + } + bridge("output", kind, data, mimeType); + } + // Primitives become their string form, everything else JSON. function outputText(value) { if (value === undefined || value === null || typeof value !== "object" && typeof value !== "function") { @@ -239,7 +268,7 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson } catch (error) { throw new TypeErrorCtor(error instanceof ErrorCtor ? error.message : String(error)); } - if (!finished) bridge("output", "text", rendered); + output("text", rendered); } function imageUrl(value) { @@ -294,7 +323,7 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson 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]); + output("image", data, signature[0]); } function exit() { @@ -312,7 +341,7 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson const console = {}; for (const level of ["log", "info", "warn", "error", "debug"]) { console[level] = (...args) => { - if (!finished) bridge("output", "text", args.map(format).join(" ")); + output("text", args.map(format).join(" ")); }; } Object.freeze(console); diff --git a/packages/codemode/test/sandbox.test.ts b/packages/codemode/test/sandbox.test.ts index ba9283ee6..0fcd6fac4 100644 --- a/packages/codemode/test/sandbox.test.ts +++ b/packages/codemode/test/sandbox.test.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from "node:url"; import vm from "node:vm"; import { afterEach, describe, expect, it } from "vitest"; -import { CodemodeSandbox, type CodemodeTool } from "../src/index.ts"; +import { CodemodeSandbox, type CodemodeTool, MAX_OUTPUT_CHARS, MAX_OUTPUT_ITEMS } from "../src/index.ts"; import { PRELUDE_SOURCE } from "../src/runtime/prelude-source.ts"; const sandboxes: CodemodeSandbox[] = []; @@ -578,6 +578,32 @@ describe("limits and lifetime", () => { expect(await promise).toMatchObject({ ok: false, error: { kind: "aborted", message: "Sandbox closed" } }); }); + // #10283: the host keeps all output, so a script that prints in a loop must not grow it without bound. + it("fails a script whose output passes the limits, even if it catches the error", async () => { + const sandbox = createSandbox(); + for (const print of ["text(s)", "console.log(s)", 'image("data:image/png;base64," + p)']) { + const result = await sandbox.execute(` + const s = "x".repeat(1 << 20); + const p = "iVBORw0KGgoA" + "A".repeat(1 << 20); + for (;;) { try { ${print}; } catch {} } + `); + expect(result).toMatchObject({ + ok: false, + error: { kind: "script", name: "RangeError", message: expect.stringContaining("script output exceeded") }, + }); + const chars = result.output.reduce( + (sum, item) => sum + (item.type === "text" ? item.text.length : item.data.length), + 0, + ); + expect(chars).toBeLessThanOrEqual(MAX_OUTPUT_CHARS); + expect(chars).toBeGreaterThan(MAX_OUTPUT_CHARS - (2 << 20)); + } + + const empty = await sandbox.execute(`for (;;) text("");`); + expect(empty).toMatchObject({ ok: false, error: { name: "RangeError" } }); + expect(empty.output).toHaveLength(MAX_OUTPUT_ITEMS); + }); + it("rejects execute after close", async () => { const sandbox = createSandbox(); await sandbox.close(); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a9c035cc9..ae540b7f4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,6 +20,7 @@ - Fixed the shrinkwrap shipping vulnerable `brace-expansion` 5.0.9 by pinning `brace-expansion` 5.0.12 as a direct dependency (GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p) ([#10288](https://github.com/earendil-works/pi/issues/10288)) - Fixed a trailing comma in `--models` adding an extra model to the model cycle ([#10334](https://github.com/earendil-works/pi/issues/10334)) +- Fixed a `codemode` script that prints in a loop crashing pi by running out of memory: a script fails once its output passes 16 Mi characters or 100000 items ([#10283](https://github.com/earendil-works/pi/issues/10283)) ## [1.0.0] - 2026-10-01 diff --git a/packages/coding-agent/docs/codemode.md b/packages/coding-agent/docs/codemode.md index 2ccee8e68..08136c00a 100644 --- a/packages/coding-agent/docs/codemode.md +++ b/packages/coding-agent/docs/codemode.md @@ -12,7 +12,7 @@ A script may start with an options line: // @options: {"max_output_tokens": 2000, "timeout_ms": 60000} ``` -- `max_output_tokens` (default 10000) limits the output. Longer output keeps its start and end, and the full text is written to a temp file whose path is included in the result. +- `max_output_tokens` (default 10000) limits the output. Longer output keeps its start and end, and the full text is written to a temp file whose path is included in the result. A script fails when its output passes 16777216 characters of text and base64 image data or 100000 `text()`, `image()`, and `console` calls; write large data to a file with a tool instead. - `timeout_ms` is a hard deadline for the whole script. It is unset by default. Image generation can take minutes, so do not set a short deadline for scripts that generate images. The result starts with `Script completed` or `Script failed`, the wall time, and the output. A failed script keeps its partial output, followed by `Script error:` and the error. Tool calls are real: calls made before a failure are not undone. Calls still running when the script ends are cancelled, and unawaited promises are discarded.