diff --git a/docs/process-capture.md b/docs/process-capture.md index 6673cd1fc0..57df119a9d 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -257,7 +257,9 @@ A bare executable name uses the scenario's `PATH` override, then the inherited An explicitly empty `PATH` searches only the working directory. Executable, working-directory, and observation paths preserve native `symlink/..` traversal. -Terminal dimensions must satisfy `columns × (rows + scrollback) ≤ 1,000,000`. +Terminal width must be at least two columns: the pinned xterm renderer cannot +represent a one-column screen. This applies to the initial size and every resize. +Terminal dimensions must also 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 diff --git a/src/domain/process/processScenario.terminalCompatibility.test.ts b/src/domain/process/processScenario.terminalCompatibility.test.ts new file mode 100644 index 0000000000..1459dfbf17 --- /dev/null +++ b/src/domain/process/processScenario.terminalCompatibility.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from "vitest"; + +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "./processCaptureExample.js"; +import { parseProcessCapture } from "./processCaptureParsing.js"; +import { digestProcessCommitment } from "./processScenario.js"; + +it("retains a previously recorded one-column scenario in process Evidence", () => { + const scenario = { + ...EMPTY_PROCESS_CAPTURE_EXAMPLE.manifest.scenario, + terminal: { columns: 1, rows: 2, scrollback: 0 }, + }; + const capture = { + ...EMPTY_PROCESS_CAPTURE_EXAMPLE, + manifest: { + ...EMPTY_PROCESS_CAPTURE_EXAMPLE.manifest, + scenario, + full_scenario_sha256: digestProcessCommitment(scenario), + }, + }; + expect(parseProcessCapture(capture).manifest.scenario).toEqual(scenario); +}); diff --git a/src/domain/process/processScenario.test.ts b/src/domain/process/processScenario.test.ts index 3aa5a00664..2c2e1e5c80 100644 --- a/src/domain/process/processScenario.test.ts +++ b/src/domain/process/processScenario.test.ts @@ -189,9 +189,9 @@ it("admits the default and the exact combined terminal-cell budget", () => { processScenarioSchema.safeParse({ executable: "/bin/true", terminal: { - columns: 1, + columns: 2, rows: 1, - scrollback: PROCESS_TERMINAL_CELL_BUDGET - 1, + scrollback: PROCESS_TERMINAL_CELL_BUDGET / 2 - 1, }, }).success, ).toBe(true); @@ -250,7 +250,7 @@ it("applies the selected scrollback budget to every scheduled resize", () => { expect( processScenarioSchema.safeParse({ executable: "/bin/true", - terminal: { columns: 1, rows: 1, scrollback: 0 }, + terminal: { columns: 2, 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 1a867034dd..0bafd5c163 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -14,6 +14,8 @@ export const processScenarioOutputBudgetProjectionSchema = z * before row objects, serialization strings, and retained frame copies. */ export const PROCESS_TERMINAL_CELL_BUDGET = 1_000_000; +/** The pinned xterm renderer cannot represent a one-column terminal. */ +export const PROCESS_TERMINAL_MIN_COLUMNS = 2; const timedEventBase = { at_ms: z.int().safe().nonnegative() }; const reservedRunIdEnvironmentName = "REA_PROCESS_RUN_ID"; const ENVIRONMENT_NAME_CHARACTER = "[^=\\x00]"; @@ -186,12 +188,16 @@ export const processScenarioSchema = z .default([]), terminal: z .strictObject({ - columns: z.int().min(1).max(65_535).default(80), + columns: z + .int() + .min(PROCESS_TERMINAL_MIN_COLUMNS) + .max(65_535) + .default(80), rows: z.int().min(1).max(65_535).default(24), scrollback: z.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. This release has no runtime override.", + "The renderer requires at least 2 columns. The visible terminal and scrollback together must use at most 1,000,000 xterm renderer cells: columns × (rows + scrollback). Resize events use the same minimum width, scrollback and limit. This release has no runtime override.", ) .default({ columns: 80, rows: 24, scrollback: 1_000 }), events: z @@ -206,7 +212,7 @@ export const processScenarioSchema = z z.strictObject({ ...timedEventBase, type: z.literal("resize"), - columns: z.int().min(1).max(65_535), + columns: z.int().min(PROCESS_TERMINAL_MIN_COLUMNS).max(65_535), rows: z.int().min(1).max(65_535), }), z.strictObject({ diff --git a/tests/acceptance/process/processTerminalWidth.test.ts b/tests/acceptance/process/processTerminalWidth.test.ts new file mode 100644 index 0000000000..221756a8dd --- /dev/null +++ b/tests/acceptance/process/processTerminalWidth.test.ts @@ -0,0 +1,83 @@ +import { access, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; +import { CAPTURE_SKIP_REASON } from "../../boundary/process/processCaptureCapability.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "rejects one-column capture and resize before starting the target", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-process-width-"); + const marker = join(root, "launched"); + const base = { + executable: process.execPath, + arguments: [ + "-e", + `require('fs').writeFileSync(${JSON.stringify(marker)}, 'started')`, + ], + terminal: { columns: 2, rows: 2, scrollback: 0 }, + }; + const scenarios = [ + { ...base, terminal: { ...base.terminal, columns: 1 } }, + { ...base, events: [{ type: "resize", at_ms: 0, columns: 1, rows: 2 }] }, + ]; + const { client, call } = await connectLocalToolsMcp(); + const definition = (await client.listTools()).tools.find( + ({ name }) => name === "capture_process_scenario", + ); + expect(definition).toBeDefined(); + const validate = new Ajv2020({ strict: false }).compile( + definition!.inputSchema, + ); + for (const [index, scenario] of scenarios.entries()) { + expect(validate(scenario)).toBe(false); + expect((await call("capture_process_scenario", scenario)).isError).toBe( + true, + ); + const path = join(root, `scenario-${String(index)}.json`); + await writeFile(path, JSON.stringify(scenario)); + const result = await cli.run({ + arguments: ["capture-process", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ code: "invalid_request" }); + await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); + } + expect(validate(base)).toBe(true); + }, +); + +cliTest.skipIf(CAPTURE_SKIP_REASON)( + "retains the requested two-column screen through capture and resize", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-process-width-control-"); + const path = join(root, "scenario.json"); + await writeFile( + path, + JSON.stringify({ + executable: process.execPath, + arguments: ["-e", "process.stdout.write('ab');setTimeout(()=>{}, 100)"], + terminal: { columns: 2, rows: 2, scrollback: 0 }, + events: [{ type: "resize", at_ms: 20, columns: 2, rows: 2 }], + timeout_ms: 5_000, + }), + ); + const result = await cli.run({ + arguments: ["capture-process", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const capture = parseProcessCapture( + parseEvidence(result.json).normalized_result, + ); + expect(capture.rendered_frames.length).toBeGreaterThan(0); + expect(capture.rendered_frames.every((frame) => frame.columns === 2)).toBe( + true, + ); + }, +);