Retain process input and unexpected failure diagnostics through the shared error projector. Preserve filesystem causes and firmware validation issues, and mark unchanged invalid requests nonretryable. Use typed stat settlement and parse unknown command failures without losing null or undefined rejection causes. Refs #5397, #5401, #5402, #5642
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import { readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
|
|
|
import {
|
|
captureProcessScenarioFile,
|
|
compareProcessEvidenceFiles,
|
|
projectProcessCliError,
|
|
} from "../../../src/application/process/ProcessCli.js";
|
|
import { createEvidence } from "../../../src/domain/evidence.js";
|
|
import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js";
|
|
import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js";
|
|
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
|
|
import {
|
|
analysisCliErrorEnvelopeSchema,
|
|
analysisErrorProjectionSchema,
|
|
} from "../../../src/contracts/errorSchemas.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const CLI_INTEGRATION_TIMEOUT_MS = 60_000;
|
|
|
|
describe("documented process CLI workflow", () => {
|
|
cliTest(
|
|
"feeds the documented JSON capture into the comparison CLI",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory("rea-process-cli-");
|
|
const guide = await readFile(
|
|
new URL("../../../docs/process-capture.md", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const recipe = /```sh\nrea capture-process ([^\n]+)\n```/u.exec(
|
|
guide,
|
|
)?.[1];
|
|
if (recipe === undefined)
|
|
throw new Error("Missing process capture recipe");
|
|
const [argumentsText, output] = recipe.split(" > ");
|
|
if (argumentsText === undefined || output === undefined)
|
|
throw new Error("Missing process capture redirection");
|
|
const args = argumentsText.split(/\s+/u);
|
|
const input = args[0];
|
|
if (input === undefined) throw new Error("Missing scenario path");
|
|
await writeFile(
|
|
join(root, input),
|
|
JSON.stringify({
|
|
executable: process.execPath,
|
|
arguments: ["-e", "process.stdout.write('documented-capture')"],
|
|
}),
|
|
);
|
|
const capture = await cli.run({
|
|
arguments: ["capture-process", ...args],
|
|
cwd: root,
|
|
});
|
|
expect(capture.exitCode, capture.stdout || capture.stderr).toBe(0);
|
|
expect(capture.json).toMatchObject({
|
|
operation: "capture_process_scenario",
|
|
});
|
|
await writeFile(join(root, output), capture.stdout);
|
|
const comparison = await cli.run({
|
|
arguments: ["compare-process-captures", output, output, "--json"],
|
|
cwd: root,
|
|
});
|
|
expect(comparison.exitCode, comparison.stdout || comparison.stderr).toBe(
|
|
0,
|
|
);
|
|
expect(comparison.json).toMatchObject({
|
|
operation: "compare_process_captures",
|
|
});
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("process CLI errors", () => {
|
|
cliTest(
|
|
"returns canonical JSON errors for missing and malformed process files",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory("rea-process-cli-errors-");
|
|
const malformed = join(root, "malformed.json");
|
|
await writeFile(malformed, "not-json");
|
|
const cases = [
|
|
["capture-process", join(root, "missing.json"), "--json"],
|
|
["capture-process", malformed, "--json"],
|
|
[
|
|
"compare-process-captures",
|
|
join(root, "missing-left.json"),
|
|
join(root, "missing-right.json"),
|
|
"--json",
|
|
],
|
|
] as const;
|
|
|
|
for (const arguments_ of cases) {
|
|
const result = await cli.run({
|
|
arguments: arguments_,
|
|
cwd: root,
|
|
});
|
|
expect(result.exitCode).toBe(1);
|
|
expect(
|
|
analysisCliErrorEnvelopeSchema.safeParse(result.json).success,
|
|
).toBe(true);
|
|
expect(result.json).toMatchObject({
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
retryable: false,
|
|
details: {
|
|
operation: expect.stringMatching(
|
|
/^(capture_process_scenario|compare_process_captures)$/u,
|
|
),
|
|
issues: [
|
|
expect.objectContaining({
|
|
path: ["input_file"],
|
|
reason: "invalid_value",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
const full = await cli.run({
|
|
arguments: [
|
|
"capture-process",
|
|
join(root, "missing.json"),
|
|
"--json",
|
|
"--full-output",
|
|
],
|
|
cwd: root,
|
|
});
|
|
expect(full.exitCode).toBe(1);
|
|
expect(full.json).toMatchObject({ ok: false });
|
|
if (typeof full.json !== "object" || full.json === null)
|
|
throw new Error("Expected a full-output CLI error document");
|
|
const fullError = Reflect.get(full.json, "error");
|
|
expect(analysisErrorProjectionSchema.safeParse(fullError).success).toBe(
|
|
true,
|
|
);
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"exits unsuccessfully without writing failure-shaped evidence",
|
|
async () => {
|
|
await expect(
|
|
execFileAsync(
|
|
process.execPath,
|
|
["scripts/rea.mjs", "capture-process", "/missing/scenario.json"],
|
|
{ cwd: process.cwd() },
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 1,
|
|
stdout: expect.stringContaining("category: invalid_input"),
|
|
});
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|
|
|
|
it("rejects NUL arguments before process launch", async () => {
|
|
const root = await createTestTempDirectory("rea-process-cli-");
|
|
const scenario = join(root, "nul-argument.json");
|
|
await writeFile(
|
|
scenario,
|
|
JSON.stringify({ executable: process.execPath, arguments: ["\0"] }),
|
|
);
|
|
|
|
expect(await captureProcessScenarioFile(scenario)).toMatchObject({
|
|
error: "Process command failed",
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
details: {
|
|
operation: "capture_process_scenario",
|
|
issues: [
|
|
{
|
|
path: ["arguments", 0],
|
|
reason: "invalid_format",
|
|
expected: "regex",
|
|
message:
|
|
"Values passed to operating-system APIs cannot contain NUL",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("process CLI environment key diagnostics", () => {
|
|
it("reports the reserved process environment key constraint", async () => {
|
|
const root = await createTestTempDirectory("rea-process-cli-");
|
|
const scenario = join(root, "reserved-environment.json");
|
|
await writeFile(
|
|
scenario,
|
|
JSON.stringify({
|
|
executable: process.execPath,
|
|
environment: { REA_PROCESS_RUN_ID: "caller-value" },
|
|
}),
|
|
);
|
|
|
|
expect(await captureProcessScenarioFile(scenario)).toMatchObject({
|
|
error: "Process command failed",
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
details: {
|
|
operation: "capture_process_scenario",
|
|
issues: [
|
|
{
|
|
path: ["environment", "REA_PROCESS_RUN_ID"],
|
|
reason: "invalid_format",
|
|
expected: "regex",
|
|
message: "REA_PROCESS_RUN_ID is reserved by the process adapter",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("process CLI evidence validation", () => {
|
|
it("preserves sanitized unknown-process IDs and reasons through CLI projection", () => {
|
|
const reason =
|
|
"process ownership token could not be read for 1 live process(es): environment_unavailable=1; live candidates 900=environment_unavailable";
|
|
const projected = projectProcessCliError(
|
|
new ProcessCaptureError("cleanup incomplete", {
|
|
reason: "cleanup_incomplete",
|
|
cleanup: { reason, resources: ["owned_process_group"] },
|
|
cleanupReport: {
|
|
owned_process_group: { state: "unverified", reason },
|
|
terminal_renderer: { state: "cleaned", reason: null },
|
|
temporary_root: { state: "cleaned", reason: null },
|
|
},
|
|
}),
|
|
"compare_process_captures",
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
cleanup_report: {
|
|
owned_process_group: { reason },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("captures the minimal executable-and-arguments scenario", async () => {
|
|
const root = await createTestTempDirectory("rea-process-cli-");
|
|
const scenario = join(root, "scenario.json");
|
|
await writeFile(
|
|
scenario,
|
|
JSON.stringify({
|
|
executable: process.execPath,
|
|
arguments: ["-e", "process.stdout.write('minimal-capture')"],
|
|
}),
|
|
);
|
|
const evidence = await captureProcessScenarioFile(scenario);
|
|
expect(evidence).toMatchObject({
|
|
predicate_type: "rea.process-capture",
|
|
operation: "capture_process_scenario",
|
|
});
|
|
expect(JSON.stringify(evidence)).toContain("minimal-capture");
|
|
});
|
|
|
|
it("rejects unrelated capture evidence", async () => {
|
|
const root = await createTestTempDirectory("rea-process-cli-");
|
|
const invalidCapture = join(root, "invalid-capture.json");
|
|
const unrelated = join(root, "unrelated.json");
|
|
await writeFile(
|
|
invalidCapture,
|
|
JSON.stringify(
|
|
createEvidence(undefined, PROCESS_PROVIDER, {
|
|
predicateType: "rea.process-capture/other",
|
|
operation: "capture_process_scenario",
|
|
parameters: {},
|
|
result: {},
|
|
}),
|
|
),
|
|
);
|
|
await writeFile(
|
|
unrelated,
|
|
JSON.stringify(
|
|
createEvidence(undefined, PROCESS_PROVIDER, {
|
|
predicateType: "unrelated/v1",
|
|
operation: "other",
|
|
parameters: {},
|
|
result: {},
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
await compareProcessEvidenceFiles(invalidCapture, invalidCapture),
|
|
).toMatchObject({
|
|
error: "Process command failed",
|
|
category: "invalid_input",
|
|
});
|
|
const failure = await compareProcessEvidenceFiles(unrelated, unrelated);
|
|
expect(analysisCliErrorEnvelopeSchema.safeParse(failure).success).toBe(
|
|
true,
|
|
);
|
|
expect(failure).toMatchObject({
|
|
error: "Process command failed",
|
|
code: "invalid_request",
|
|
retryable: false,
|
|
category: "invalid_input",
|
|
message:
|
|
"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",
|
|
details: {
|
|
operation: "compare_process_captures",
|
|
issues: [{ path: ["left"], reason: "invalid_value" }],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("classifies malformed capture evidence as invalid input", async () => {
|
|
const root = await createTestTempDirectory("rea-process-cli-");
|
|
const malformed = join(root, "malformed-evidence.json");
|
|
await writeFile(malformed, "{}");
|
|
|
|
expect(
|
|
await compareProcessEvidenceFiles(malformed, malformed),
|
|
).toMatchObject({
|
|
error: "Process command failed",
|
|
category: "invalid_input",
|
|
code: "invalid_request",
|
|
details: {
|
|
operation: "compare_process_captures",
|
|
issues: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: ["parameters"],
|
|
reason: "missing_argument",
|
|
}),
|
|
]),
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
it(
|
|
"reports the JSON depth constraint through the compiled comparison CLI",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-process-cli-");
|
|
const left = join(root, "left.json");
|
|
const right = join(root, "right.json");
|
|
const input = INVESTIGATION_EXAMPLES.compare_process_captures.input;
|
|
await writeFile(left, JSON.stringify(input.left));
|
|
await writeFile(right, JSON.stringify(input.right));
|
|
const cli = fileURLToPath(
|
|
new URL("../../../scripts/rea.mjs", import.meta.url),
|
|
);
|
|
const cliArguments = [
|
|
cli,
|
|
"compare-process-captures",
|
|
left,
|
|
right,
|
|
"--json",
|
|
];
|
|
const baseline = await execFileAsync(process.execPath, cliArguments);
|
|
expect(JSON.parse(baseline.stdout)).toMatchObject({
|
|
operation: "compare_process_captures",
|
|
});
|
|
const nested = '{"nested":'.repeat(10_000) + "1" + "}".repeat(10_000);
|
|
await writeFile(
|
|
left,
|
|
JSON.stringify({
|
|
...input.left,
|
|
parameters: { attack: "depth-placeholder" },
|
|
}).replace('"depth-placeholder"', nested),
|
|
);
|
|
await expect(
|
|
execFileAsync(process.execPath, cliArguments),
|
|
).rejects.toMatchObject({
|
|
code: 1,
|
|
stdout: expect.stringContaining("maximum nesting depth"),
|
|
});
|
|
},
|
|
CLI_INTEGRATION_TIMEOUT_MS,
|
|
);
|