mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
e50a271c72
Add shared, typed input-contract and timeout-state recovery guidance. Normalize SDK input failures within the actual MCP wire budget, preserve full diagnostics and protocol metadata, and retain original Evidence when normalized delivery would exceed the budget. Fixes #5431.
292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
|
import {
|
|
captureProcessScenarioFile,
|
|
compareProcessEvidenceFiles,
|
|
projectProcessCliError,
|
|
} from "../../../src/application/process/ProcessCli.js";
|
|
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js";
|
|
import { createEvidence } from "../../../src/domain/evidence.js";
|
|
import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js";
|
|
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
|
|
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
|
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { holdFirstFileRead } from "../../support/holdFirstFileRead.js";
|
|
|
|
describe("process CLI error projection", () => {
|
|
it("preserves local diagnostics for unexpected process failures", () => {
|
|
const projected = projectProcessCliError(
|
|
new Error("SECRET internal failure /private/path"),
|
|
);
|
|
expect(projected).toEqual({
|
|
error: "Process command failed",
|
|
category: "execution_failure",
|
|
message:
|
|
"Process command could not complete: SECRET internal failure /private/path",
|
|
});
|
|
expect(JSON.stringify(projected)).not.toContain("stack");
|
|
});
|
|
});
|
|
|
|
describe("process CLI file cancellation", () => {
|
|
it("projects pre-aborted file reads as cancellation", async () => {
|
|
const root = await createTestTempDirectory("rea-process-cli-cancel-");
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
expect(
|
|
await captureProcessScenarioFile(
|
|
join(root, "missing-scenario.json"),
|
|
process.env,
|
|
controller.signal,
|
|
),
|
|
).toMatchObject({
|
|
error: "Process command failed",
|
|
code: "cancelled",
|
|
category: "cancelled",
|
|
details: { operation: "capture_process_scenario" },
|
|
});
|
|
expect(
|
|
await compareProcessEvidenceFiles(
|
|
join(root, "missing-left.json"),
|
|
join(root, "missing-right.json"),
|
|
join(root, "missing-trace.json"),
|
|
controller.signal,
|
|
),
|
|
).toMatchObject({
|
|
code: "cancelled",
|
|
category: "cancelled",
|
|
details: { operation: "compare_process_captures" },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ workflow: "capture", input: "scenario" },
|
|
{ workflow: "compare", input: "left" },
|
|
{ workflow: "compare", input: "right" },
|
|
{ workflow: "compare", input: "trace specification" },
|
|
] as const)(
|
|
"stops streamed $workflow $input reads after cancellation",
|
|
async ({ workflow, input }) => {
|
|
const root = await createTestTempDirectory("rea-process-cli-stream-");
|
|
const paths = {
|
|
scenario: join(root, "scenario.json"),
|
|
left: join(root, "left.json"),
|
|
right: join(root, "right.json"),
|
|
trace: join(root, "trace.json"),
|
|
};
|
|
const evidence = JSON.stringify(processCaptureEvidence());
|
|
const scenario = JSON.stringify({
|
|
executable: process.execPath,
|
|
arguments: [],
|
|
});
|
|
const traceSpecification = JSON.stringify(validTraceSpecification());
|
|
const contentsByInput = {
|
|
scenario: input === "scenario" ? stream(scenario) : scenario,
|
|
left: input === "left" ? stream(evidence) : evidence,
|
|
right: input === "right" ? stream(evidence) : evidence,
|
|
trace:
|
|
input === "trace specification"
|
|
? stream(traceSpecification)
|
|
: traceSpecification,
|
|
};
|
|
await Promise.all([
|
|
writeFile(paths.scenario, contentsByInput.scenario),
|
|
writeFile(paths.left, contentsByInput.left),
|
|
writeFile(paths.right, contentsByInput.right),
|
|
writeFile(paths.trace, contentsByInput.trace),
|
|
]);
|
|
const targetPath =
|
|
paths[input === "trace specification" ? "trace" : input];
|
|
const contents =
|
|
contentsByInput[input === "trace specification" ? "trace" : input];
|
|
const reader = await holdFirstFileRead(targetPath);
|
|
onTestFinished(reader.release);
|
|
const controller = new AbortController();
|
|
try {
|
|
const pending =
|
|
workflow === "capture"
|
|
? captureProcessScenarioFile(
|
|
paths.scenario,
|
|
process.env,
|
|
controller.signal,
|
|
)
|
|
: compareProcessEvidenceFiles(
|
|
paths.left,
|
|
paths.right,
|
|
paths.trace,
|
|
controller.signal,
|
|
);
|
|
await reader.firstRead;
|
|
controller.abort();
|
|
reader.release();
|
|
expect(await pending).toMatchObject({
|
|
error: "Process command failed",
|
|
code: "cancelled",
|
|
category: "cancelled",
|
|
});
|
|
expect(reader.readCount).toBe(1);
|
|
expect(reader.totalReadBytes).toBeLessThan(contents.length);
|
|
} finally {
|
|
controller.abort();
|
|
reader.release();
|
|
vi.restoreAllMocks();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("process CLI input diagnostics", () => {
|
|
it("projects missing, malformed JSON, and invalid scenario files", async () => {
|
|
const root = await createTestTempDirectory("rea-process-cli-diagnostics-");
|
|
const malformed = join(root, "malformed.json");
|
|
const invalid = join(root, "invalid.json");
|
|
const invalidUtf8 = join(root, "invalid-utf8.json");
|
|
await writeFile(malformed, "not-json");
|
|
await writeFile(invalid, "{}");
|
|
await writeFile(
|
|
invalidUtf8,
|
|
Buffer.concat([
|
|
Buffer.from('{"executable":"'),
|
|
Buffer.from([0xff]),
|
|
Buffer.from('"}'),
|
|
]),
|
|
);
|
|
|
|
expect(
|
|
await captureProcessScenarioFile(join(root, "missing.json")),
|
|
).toEqual({
|
|
error: "Process command failed",
|
|
category: "invalid_input",
|
|
message: expect.stringContaining(
|
|
`Process input file could not be read: ${join(root, "missing.json")} (ENOENT`,
|
|
),
|
|
});
|
|
expect(await captureProcessScenarioFile(malformed)).toEqual({
|
|
error: "Process command failed",
|
|
category: "invalid_input",
|
|
message: expect.stringContaining(
|
|
`Process input file is not valid UTF-8 JSON: ${malformed}`,
|
|
),
|
|
});
|
|
expect(await captureProcessScenarioFile(invalidUtf8)).toEqual({
|
|
error: "Process command failed",
|
|
category: "invalid_input",
|
|
message: expect.stringContaining(
|
|
`Process input file is not valid UTF-8 JSON: ${invalidUtf8}`,
|
|
),
|
|
});
|
|
expect(await captureProcessScenarioFile(invalid)).toEqual({
|
|
error: "Process command failed",
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
message: "Analysis input is invalid. Check the arguments and try again.",
|
|
retryable: true,
|
|
remediation: {
|
|
action:
|
|
"Read the declared input schema or CLI usage for capture_process_scenario; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.",
|
|
},
|
|
recovery: {
|
|
kind: "inspect_input_contract",
|
|
operation: "capture_process_scenario",
|
|
},
|
|
details: expect.objectContaining({
|
|
operation: "capture_process_scenario",
|
|
issues: expect.arrayContaining([
|
|
expect.objectContaining({ path: ["executable"] }),
|
|
]),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"preserves the open-file owner when close fails (cancelled: %s)",
|
|
async (cancelDuringClose) => {
|
|
const root = await createTestTempDirectory("rea-process-cli-cleanup-");
|
|
const path = join(root, "scenario.json");
|
|
await writeFile(path, JSON.stringify({ executable: process.execPath }));
|
|
const controller = new AbortController();
|
|
const cleanupCause = new Error("injected file close failure");
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementationOnce(async () => {
|
|
if (cancelDuringClose) controller.abort();
|
|
throw cleanupCause;
|
|
});
|
|
let failure: unknown;
|
|
try {
|
|
try {
|
|
await captureProcessScenarioFile(
|
|
path,
|
|
process.env,
|
|
cancelDuringClose ? controller.signal : undefined,
|
|
);
|
|
} catch (cause: unknown) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
|
if (!(failure instanceof RegularFileCleanupFailure))
|
|
throw new Error("Expected the retained file handle owner");
|
|
expect(failure.cleanupCause).toBe(cleanupCause);
|
|
expect(failure.owner.handle.fd).toBeGreaterThanOrEqual(0);
|
|
} finally {
|
|
close.mockRestore();
|
|
if (failure instanceof RegularFileCleanupFailure)
|
|
await failure.owner.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("names a leading byte-order mark instead of quoting it as a token", async () => {
|
|
const path = join(
|
|
await createTestTempDirectory("rea-process-cli-diagnostics-"),
|
|
"scenario.json",
|
|
);
|
|
await writeFile(path, '\uFEFF{"executable":"/bin/echo"}');
|
|
expect(await captureProcessScenarioFile(path)).toEqual({
|
|
error: "Process command failed",
|
|
category: "invalid_input",
|
|
message: `Process input file is not valid UTF-8 JSON: ${path} (${JSON_BYTE_ORDER_MARK_MESSAGE}). Repair the file, then try again.`,
|
|
});
|
|
});
|
|
});
|
|
|
|
const processCaptureEvidence = () =>
|
|
createEvidence(undefined, PROCESS_PROVIDER, {
|
|
predicateType: "rea.process-capture",
|
|
operation: "capture_process_scenario",
|
|
parameters: {},
|
|
result: EMPTY_PROCESS_CAPTURE_EXAMPLE,
|
|
confidence: "observed",
|
|
authority: "controlled-replay",
|
|
environment: {
|
|
id: "fixture",
|
|
platform: "fixture",
|
|
architecture: "fixture",
|
|
isolation: "process",
|
|
},
|
|
});
|
|
|
|
const stream = (json: string) => " ".repeat(8 * 1024 * 1024) + json;
|
|
|
|
const validTraceSpecification = () => ({
|
|
events: [
|
|
{
|
|
id: "started",
|
|
source: "lifecycle",
|
|
exact: { state: "started" },
|
|
},
|
|
{
|
|
id: "finished",
|
|
source: "lifecycle",
|
|
exact: { state: "finished" },
|
|
},
|
|
],
|
|
language: {
|
|
kind: "partial_order",
|
|
happens_before: [{ before: "started", after: "finished" }],
|
|
},
|
|
});
|