pi-mono/packages/coding-agent/test/exec.test.ts
Christian Klotz c466a35957
fix(coding-agent): cap pi.exec output at 256 MiB (#11546)
Closes #11360.

## Summary
- Cap combined stdout/stderr with `ExecOptions.maxBuffer`, defaulting to
the smaller of 256 MiB and the runtime string limit. Invalid explicit
limits reject before spawning.
- Terminate oversized commands and return `truncated: true`, `killed:
true`, and a nonzero exit code. Close output pipes so writing
descendants cannot keep the call pending, and discard incomplete UTF-8
suffixes rather than adding replacement characters.
- Fix SIGKILL escalation for children that ignore SIGTERM, and simplify
completion to `await` with one cleanup block and result.
- Document the API and add regressions for byte limits, shell
descendants, UTF-8 chunk boundaries, and 32-bit runtime limits.

`ExecResult.truncated` is required. Existing extensions reading
execution results continue working; typed mocks or wrappers constructing
results must add the field.

## Verification
- `npm run build` and the post-rebase `npm run build:offline`
- `npm run check`
- 39 focused execution and extension tests
- `./test.sh` passes after building the env daemon. Earlier runs hit
remote-cleanup and reftable-watcher timeouts; both passed in focused
reruns and the final full run.
2026-10-29 00:13:41 +02:00

128 lines
5.1 KiB
TypeScript
Raw Permalink Blame History

import { constants as bufferConstants } from "node:buffer";
import { describe, expect, it } from "vitest";
import { execCommand } from "../src/core/exec.ts";
describe("execCommand", () => {
// Regression test for https://github.com/earendil-works/pi/issues/11085
it("reports spawn errors in stderr", async () => {
const result = await execCommand("pi-command-that-does-not-exist-11085", [], process.cwd());
expect(result).toMatchObject({
code: 1,
killed: false,
truncated: false,
stdout: "",
});
expect(result.stderr).toMatch(/ENOENT/);
});
it("keeps stderr empty when a command exits silently", async () => {
const result = await execCommand(process.execPath, ["-e", "process.exit(1)"], process.cwd());
expect(result).toEqual({ stdout: "", stderr: "", code: 1, killed: false, truncated: false });
});
// Regression tests for https://github.com/earendil-works/pi/issues/11360
it.each([
-1,
0.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
bufferConstants.MAX_STRING_LENGTH + 1,
])("rejects invalid maxBuffer %s instead of attempting execution", async (maxBuffer) => {
// A missing command normally resolves to a failed result; invalid options must reject instead.
await expect(
execCommand("pi-command-that-does-not-exist-11360", [], process.cwd(), { maxBuffer }),
).rejects.toThrow(RangeError);
});
it.each([0, bufferConstants.MAX_STRING_LENGTH])("accepts maxBuffer boundary %s", async (maxBuffer) => {
const result = await execCommand(process.execPath, ["-e", ""], process.cwd(), { maxBuffer });
expect(result).toEqual({ stdout: "", stderr: "", code: 0, killed: false, truncated: false });
});
it("truncates all output when maxBuffer is zero", async () => {
const result = await execCommand(process.execPath, ["-e", 'process.stdout.write("x")'], process.cwd(), {
maxBuffer: 0,
});
expect(result).toMatchObject({ stdout: "", stderr: "", truncated: true, killed: true });
expect(result.code).not.toBe(0);
});
it("caps combined output at maxBuffer, terminates the command, and reports truncation", async () => {
const script =
'process.stderr.write("e".repeat(600)); process.stdout.write("o".repeat(600)); setInterval(() => process.stdout.write("x".repeat(1000)), 10);';
const result = await execCommand(process.execPath, ["-e", script], process.cwd(), { maxBuffer: 1000 });
expect(result).toMatchObject({ truncated: true, killed: true });
expect(result.code).not.toBe(0);
expect(Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr)).toBe(1000);
});
// #11360: a byte cap must not turn a partial UTF-8 character into replacement output.
it.each([
["stdout", "€", 1, ""],
["stderr", "€", 1, ""],
["stdout", "a€🙂", 5, "a€"],
["stderr", "a€🙂", 5, "a€"],
] as const)(
"keeps complete UTF-8 characters from %s output %s capped at %i bytes",
async (stream, output, maxBuffer, expected) => {
const script = `process.${stream}.write(${JSON.stringify(output)})`;
const result = await execCommand(process.execPath, ["-e", script], process.cwd(), { maxBuffer });
expect(result).toMatchObject({ [stream]: expected, truncated: true, killed: true });
expect(result.code).not.toBe(0);
expect(Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr)).toBeLessThanOrEqual(maxBuffer);
},
);
it("still flushes incomplete UTF-8 at normal EOF when output was not truncated", async () => {
const script = "process.stdout.write(Buffer.from([0xe2])); process.stderr.write(Buffer.from([0xf0]));";
const result = await execCommand(process.execPath, ["-e", script], process.cwd());
expect(result).toEqual({ stdout: "<22>", stderr: "<22>", code: 0, killed: false, truncated: false });
});
it("leaves output up to maxBuffer unchanged", async () => {
const result = await execCommand(
process.execPath,
["-e", 'process.stdout.write("a".repeat(1000))'],
process.cwd(),
{
maxBuffer: 1000,
},
);
expect(result).toEqual({ stdout: "a".repeat(1000), stderr: "", code: 0, killed: false, truncated: false });
});
it.skipIf(process.platform === "win32")(
"returns when a shell's child keeps writing after the shell is terminated",
async () => {
// SIGTERM kills only `sh`; `yes` keeps the output pipes open and writing.
const result = await execCommand("sh", ["-c", "yes; echo done"], process.cwd(), { maxBuffer: 1000 });
expect(result).toMatchObject({ killed: true, truncated: true });
expect(result.stdout).toBe("y\n".repeat(500));
},
10_000,
);
it.skipIf(process.platform === "win32")(
"force-kills a command that ignores SIGTERM",
async () => {
// The handler is installed before the first write, so the kill triggered by maxBuffer
// cannot arrive before the child ignores SIGTERM.
const script = 'process.on("SIGTERM", () => {}); process.stdout.write("xx"); setInterval(() => {}, 1000);';
const result = await execCommand(process.execPath, ["-e", script], process.cwd(), { maxBuffer: 1 });
expect(result).toMatchObject({ stdout: "x", killed: true, truncated: true });
expect(result.code).not.toBe(0);
},
15_000,
);
});