mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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
This commit is contained in:
parent
eeac84ca92
commit
319fecb89b
7 changed files with 72 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue