fix(process)!: bound terminal buffers before capture

Validate columns × (rows + scrollback) against a 1,000,000-cell xterm working-buffer budget, including every resize with the selected scrollback. Document the budget in the advertised MCP input and process-capture guide; never clamp caller dimensions.

Validation: default/exact-boundary schema cases, prelaunch MCP rejection, compiled CLI capture, terminal rendering, normalization, cancellation, and lifecycle checks pass on macOS. This is resource admission, not a measured serializer throughput improvement; Linux-only and real deep-analysis providers were not exercised.

BREAKING CHANGE: process scenarios whose initial or resized terminal exceeds 1,000,000 combined visible-plus-scrollback cells are now rejected before launch.
This commit is contained in:
morluto 2026-10-11 03:14:46 +08:00
parent 78778d91a4
commit f9ea9a9268
5 changed files with 170 additions and 4 deletions

View File

@ -114,6 +114,12 @@ from launch, must be ordered, and must fall within `timeout_ms`. Inputs marked
Environment overrides are recorded in the scenario commitment; inherited
values are not copied into Evidence.
Terminal dimensions must satisfy `columns × (rows + scrollback) ≤ 1,000,000`.
Every scheduled resize uses the same selected scrollback and must fit this
working-buffer budget. REA rejects oversized scenarios before launching the
command; it never clamps the requested dimensions. This limit is independent
of `limits.output_bytes`, which bounds retained terminal observations.
The capture contains raw PTY output chunks and rendered terminal states,
interaction dispatch outcomes, exit reason, sampled process-tree observations,
and settlement status. When filesystem paths are selected, REA records

View File

@ -94,7 +94,7 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"capture_process_scenario",
"Run one caller-selected command under a PTY and return process capture Evidence with residual unknowns. A command name resolves through the inherited PATH; the working directory defaults to the caller's current directory; host environment variables are inherited with scenario overrides. Environment keys cannot contain '=' or NUL, and REA_PROCESS_RUN_ID is reserved for process ownership. Filesystem snapshots are opt-in through filesystem_observation_paths. The target runs with the current user's permissions; this is not a security sandbox.",
"Run one caller-selected command under a PTY and return process capture Evidence with residual unknowns. The renderer admits at most 1,000,000 combined terminal cells, computed as columns × (rows + scrollback); every resize uses the selected scrollback and must fit the same budget. Supply a smaller terminal shape when that product is too large; dimensions are never clamped. A command name resolves through the inherited PATH; the working directory defaults to the caller's current directory; host environment variables are inherited with scenario overrides. Environment keys cannot contain '=' or NUL, and REA_PROCESS_RUN_ID is reserved for process ownership. Filesystem snapshots are opt-in through filesystem_observation_paths. The target runs with the current user's permissions; this is not a security sandbox.",
processScenarioSchema,
),
session(

View File

@ -0,0 +1,71 @@
import { expect, it } from "vitest";
import {
parseProcessScenario,
PROCESS_TERMINAL_CELL_BUDGET,
processScenarioSchema,
} from "./processScenario.js";
it("admits the default and the exact combined terminal-cell budget", () => {
expect(parseProcessScenario({ executable: "/bin/true" }).terminal).toEqual({
columns: 80,
rows: 24,
scrollback: 1_000,
});
expect(
processScenarioSchema.safeParse({
executable: "/bin/true",
terminal: {
columns: 1,
rows: 1,
scrollback: PROCESS_TERMINAL_CELL_BUDGET - 1,
},
}).success,
).toBe(true);
});
it("rejects an oversized initial buffer without clamping caller dimensions", () => {
const input = {
executable: "/bin/true",
terminal: { columns: 1_000, rows: 1, scrollback: 1_000 },
};
const parsed = processScenarioSchema.safeParse(input);
expect(parsed.success).toBe(false);
if (parsed.success) throw new Error("expected terminal capacity rejection");
expect(parsed.error.issues).toContainEqual(
expect.objectContaining({
code: "custom",
path: ["terminal"],
message: expect.stringContaining(
`${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells`,
),
}),
);
});
it("applies the selected scrollback budget to every scheduled resize", () => {
const parsed = processScenarioSchema.safeParse({
executable: "/bin/true",
events: [{ type: "resize", at_ms: 10, columns: 1_000, rows: 1 }],
});
expect(parsed.success).toBe(false);
if (parsed.success) throw new Error("expected resize capacity rejection");
expect(parsed.error.issues).toContainEqual(
expect.objectContaining({
code: "custom",
path: ["events", 0],
message: expect.stringContaining("resize event at 10 ms"),
}),
);
expect(
processScenarioSchema.safeParse({
executable: "/bin/true",
terminal: { columns: 1, rows: 1, scrollback: 0 },
events: [{ type: "resize", at_ms: 10, columns: 1_000, rows: 1 }],
}).success,
).toBe(true);
});

View File

@ -3,6 +3,12 @@ import canonicalize from "canonicalize";
import { z } from "zod";
const positiveBudget = z.number().int().safe().positive();
/**
* Maximum xterm backing cells for one visible-plus-scrollback buffer. Each
* xterm BufferLine stores three Uint32 words per cell (12 MB at this cap),
* before row objects, serialization strings, and retained frame copies.
*/
export const PROCESS_TERMINAL_CELL_BUDGET = 1_000_000;
const timedEventBase = { at_ms: z.number().int().safe().nonnegative() };
const reservedRunIdEnvironmentName = "REA_PROCESS_RUN_ID";
const ENVIRONMENT_NAME_CHARACTER = "[^=\\x00]";
@ -41,6 +47,14 @@ const childProcessString = z
/^[^\x00]*$/u,
"Values passed to operating-system APIs cannot contain NUL",
);
const fitsProcessTerminalCellBudget = (
columns: number,
rows: number,
scrollback: number,
): boolean =>
rows + scrollback <= Math.floor(PROCESS_TERMINAL_CELL_BUDGET / columns);
export const normalizationSchema = z.object({
paths: z.boolean(),
pids: z.boolean(),
@ -131,8 +145,11 @@ export const processScenarioSchema = z
.strictObject({
columns: z.number().int().min(1).max(65_535).default(80),
rows: z.number().int().min(1).max(65_535).default(24),
scrollback: z.number().int().min(0).default(1_000),
scrollback: z.number().int().safe().nonnegative().default(1_000),
})
.describe(
"The visible terminal and scrollback together must use at most 1,000,000 xterm renderer cells: columns × (rows + scrollback). Resize events use the same scrollback and limit.",
)
.default({ columns: 80, rows: 24, scrollback: 1_000 }),
events: z
.array(
@ -204,6 +221,18 @@ export const processScenarioSchema = z
}),
})
.superRefine((scenario, context) => {
if (
!fitsProcessTerminalCellBudget(
scenario.terminal.columns,
scenario.terminal.rows,
scenario.terminal.scrollback,
)
)
context.addIssue({
code: "custom",
path: ["terminal"],
message: `terminal columns × (rows + scrollback) must not exceed ${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells`,
});
for (let index = 1; index < scenario.events.length; index += 1) {
const event = scenario.events[index];
const previous = scenario.events[index - 1];
@ -215,7 +244,20 @@ export const processScenarioSchema = z
});
}
}
for (const event of scenario.events) {
for (const [index, event] of scenario.events.entries()) {
if (
event.type === "resize" &&
!fitsProcessTerminalCellBudget(
event.columns,
event.rows,
scenario.terminal.scrollback,
)
)
context.addIssue({
code: "custom",
path: ["events", index],
message: `resize event at ${String(event.at_ms)} ms exceeds the ${String(PROCESS_TERMINAL_CELL_BUDGET)}-cell renderer budget with the selected scrollback`,
});
if (event.at_ms > scenario.timeout_ms) {
context.addIssue({
code: "custom",

View File

@ -1,8 +1,10 @@
import { fileURLToPath } from "node:url";
import { access } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { afterEach, expect } from "vitest";
import { afterEach, expect, it } from "vitest";
import { z } from "zod";
import { toolContract } from "../../../src/contracts/toolContracts.js";
@ -13,6 +15,7 @@ import {
import { createServer } from "../../../src/server/createServer.js";
import { silentLogger } from "../../../src/logger.js";
import { itWithCaptureCapability } from "./processCaptureCapability.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const processFixture = fileURLToPath(
new URL("../../fixtures/processFidelity.mjs", import.meta.url),
@ -23,6 +26,50 @@ afterEach(async () => {
await Promise.all(resources.splice(0).map((resource) => resource.close()));
});
it("rejects oversized initial and resize terminal buffers through MCP before launching", async () => {
const root = await createTestTempDirectory("rea-terminal-admission-");
const marker = join(root, "launched");
const session = createTestBinarySession(() => {
throw new Error("Terminal admission must not launch a binary provider");
});
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const client = new Client({ name: "terminal-admission", version: "1" });
resources.push(client, server, session);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
for (const input of [
{ terminal: { columns: 1_000, rows: 1, scrollback: 1_000 } },
{ events: [{ type: "resize", at_ms: 10, columns: 1_000, rows: 1 }] },
] as const) {
const result = await client.callTool({
name: "capture_process_scenario",
arguments: {
executable: process.execPath,
arguments: [
"-e",
"require('node:fs').writeFileSync(process.argv[1], 'launched')",
marker,
],
...input,
},
});
expect(result.isError).toBe(true);
expect(result.content).toContainEqual(
expect.objectContaining({
type: "text",
text: expect.stringContaining("1000000"),
}),
);
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
}
await client.ping();
});
itWithCaptureCapability(
"records process residuals linked to capture Evidence",
async () => {