diff --git a/docs/process-capture.md b/docs/process-capture.md index 63810d629..772b39602 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -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 diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 6c610b984..65c29785f 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -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( diff --git a/src/domain/process/processScenario.test.ts b/src/domain/process/processScenario.test.ts new file mode 100644 index 000000000..be7075502 --- /dev/null +++ b/src/domain/process/processScenario.test.ts @@ -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); +}); diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index ff9197d15..c489db92e 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -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", diff --git a/tests/boundary/process/processSessionMcp.test.ts b/tests/boundary/process/processSessionMcp.test.ts index 8b6791df9..7717c7f4e 100644 --- a/tests/boundary/process/processSessionMcp.test.ts +++ b/tests/boundary/process/processSessionMcp.test.ts @@ -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 () => {