mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
fix(firmware): keep a missing producer report distinct after exit 0 (#6008)
* fix(firmware): keep a missing producer report distinct after exit 0 A firmware engine that exits 0 without writing its requested report now fails with the observed report state (missing, empty, invalid_json, oversized, not_regular_file), the command, arguments, exit status and captured output, plus report-specific recovery instead of generic `rea doctor` advice. REA still does not retry or substitute an empty result. Fixes #5876 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(firmware): keep missing-report recovery from replaying removed paths The recorded command arguments name REA's private workspace, which is removed after the attempt. Recovery now presents them as provenance and tells callers to replace every private path when reproducing the run. Cover the oversized and not_regular_file report states. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4423b6fff2
commit
c8d3f8f8ec
5 files changed
+210
-51
No files matched your search
@@ -183,6 +183,9 @@ tree semantics, vulnerability scanning, emulation or hardware runtime capture.
|
||||
120 CPU seconds and zero core-dump allowance through `prlimit`.
|
||||
- JSON reports: at most **8 MiB** and valid UTF-8; malformed encoding and
|
||||
oversized reports fail without repairing or truncating producer data.
|
||||
Exit status 0 is not an empty result: a missing, empty, malformed or
|
||||
oversized report fails with `unreadable_output`, its `report_state`, the
|
||||
command, exit status and captured output. REA does not retry automatically.
|
||||
Diagnostic output is bounded with exact captured byte counts.
|
||||
- Default staging budget: **256 MiB** and **10,000 entries**, configurable using
|
||||
`max_output_bytes` / `max_output_files` (CLI `--max-output-bytes` /
|
||||
|
||||
@@ -291,7 +291,12 @@ export const runFirmwareCommand = async (context: {
|
||||
};
|
||||
};
|
||||
|
||||
/** Preserve tool failure diagnostics when an accepted exit 1 has no usable report. */
|
||||
/**
|
||||
* Consume the report a completed command was asked to write. A successful exit
|
||||
* status is not analysis Evidence by itself: an absent, empty or malformed report
|
||||
* fails with the observed report state and command facts, without retrying or
|
||||
* substituting an empty result.
|
||||
*/
|
||||
export const readFirmwareCommandReport = async (context: {
|
||||
path: string;
|
||||
engine: string;
|
||||
@@ -308,33 +313,43 @@ export const readFirmwareCommandReport = async (context: {
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
if (context.execution.exit_code === 1)
|
||||
throw new ProviderAdapterError(context.engine, context.operation, {
|
||||
const { engine, operation, execution } = context;
|
||||
const reportFailure =
|
||||
cause instanceof AnalysisOutputError
|
||||
? cause.reason
|
||||
: `Report could not be read: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
const diagnostics = {
|
||||
...(cause instanceof AnalysisOutputError ? cause.diagnostics : {}),
|
||||
report_path: context.path,
|
||||
report_failure: reportFailure,
|
||||
command: execution.command,
|
||||
args: execution.args,
|
||||
resource_limiter: execution.resource_limiter,
|
||||
exit_code: execution.exit_code ?? null,
|
||||
signal: execution.signal,
|
||||
stdout: execution.stdout.text,
|
||||
stderr: execution.stderr.text,
|
||||
stdout_bytes: execution.stdout.bytes,
|
||||
stderr_bytes: execution.stderr.bytes,
|
||||
};
|
||||
if (execution.exit_code === 1)
|
||||
throw new ProviderAdapterError(engine, operation, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
reason:
|
||||
"Tool exited 1 without a usable analysis report; inspect stderr for startup, sandbox or dependency failures",
|
||||
report_failure:
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
exit_code: 1,
|
||||
signal: context.execution.signal,
|
||||
stdout: context.execution.stdout.text,
|
||||
stderr: context.execution.stderr.text,
|
||||
stdout_bytes: context.execution.stdout.bytes,
|
||||
stderr_bytes: context.execution.stderr.bytes,
|
||||
...diagnostics,
|
||||
},
|
||||
});
|
||||
const diagnostics = `stdout (${context.execution.stdout.bytes} bytes): ${context.execution.stdout.text}; stderr (${context.execution.stderr.bytes} bytes): ${context.execution.stderr.text}`;
|
||||
if (cause instanceof AnalysisOutputError)
|
||||
throw new AnalysisOutputError(
|
||||
context.operation,
|
||||
`${cause.reason}; ${diagnostics}`,
|
||||
{ cause },
|
||||
);
|
||||
throw new AnalysisOutputError(
|
||||
context.operation,
|
||||
`Tool did not produce the expected complete report: ${context.path}; ${diagnostics}`,
|
||||
{ cause },
|
||||
operation,
|
||||
`${engine} exited ${String(execution.exit_code)} without a usable report: ${reportFailure}`,
|
||||
{
|
||||
cause,
|
||||
diagnostics,
|
||||
userMessage: `${engine} exited successfully but left no usable report (${reportFailure}), so ${operation} produced no Evidence. A successful exit status is not an empty or complete result.`,
|
||||
remediationAction: `Review diagnostics.report_state, the exit status and captured stdout/stderr. REA did not retry or substitute an empty result. diagnostics.command and diagnostics.args record what ran; their workspace paths are REA-private and removed after the attempt, so they cannot be replayed as-is. Rerun the same REA request to check whether this is intermittent; each attempt uses a fresh workspace. If it recurs, copy the selected input bytes into a new directory and run diagnostics.command with the same flags, replacing every private path with your copy and a new report path, under the documented firmware resource limits; confirm that it writes the requested report. \`rea doctor\` checks configuration only and cannot recover this report.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -188,7 +188,11 @@ export class FirmwareFileAccess {
|
||||
);
|
||||
}
|
||||
|
||||
/** Read a complete bounded report; oversized data is never reported as complete. */
|
||||
/**
|
||||
* Read a complete bounded report; oversized data is never reported as complete.
|
||||
* Each refusal records its observed report state, so an absent or empty report
|
||||
* is never conflated with an empty scan or a malformed one.
|
||||
*/
|
||||
async report(
|
||||
path: string,
|
||||
operation: string,
|
||||
@@ -201,11 +205,14 @@ export class FirmwareFileAccess {
|
||||
FIRMWARE_LIMITS.reportBytes,
|
||||
);
|
||||
if (bytes === undefined)
|
||||
throw new AnalysisOutputError(
|
||||
throw firmwareReportFailure(
|
||||
operation,
|
||||
"oversized",
|
||||
`Report exceeded ${FIRMWARE_LIMITS.reportBytes} bytes`,
|
||||
);
|
||||
assertArtifactReadActive(signal);
|
||||
if (bytes.length === 0)
|
||||
throw firmwareReportFailure(operation, "empty", "Report is empty");
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
@@ -214,25 +221,57 @@ export class FirmwareFileAccess {
|
||||
);
|
||||
return value;
|
||||
} catch (cause: unknown) {
|
||||
throw new AnalysisOutputError(
|
||||
throw firmwareReportFailure(
|
||||
operation,
|
||||
"invalid_json",
|
||||
"Tool report is not valid complete JSON",
|
||||
{ cause },
|
||||
cause,
|
||||
);
|
||||
}
|
||||
}).catch((cause: unknown) => {
|
||||
throw firmwareAdmissionFailure(
|
||||
cause,
|
||||
(primary) =>
|
||||
new AnalysisOutputError(operation, "Report is not a regular file", {
|
||||
cause: primary,
|
||||
}),
|
||||
throw mapArtifactFileFailure(cause, (primary) =>
|
||||
primary instanceof NonRegularFileReadError
|
||||
? firmwareReportFailure(
|
||||
operation,
|
||||
"not_regular_file",
|
||||
"Report is not a regular file",
|
||||
primary,
|
||||
)
|
||||
: primary instanceof Error &&
|
||||
"code" in primary &&
|
||||
primary.code === "ENOENT"
|
||||
? firmwareReportFailure(
|
||||
operation,
|
||||
"missing",
|
||||
"Report was not created",
|
||||
primary,
|
||||
)
|
||||
: primary,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Observed state of a producer report that cannot supply analysis Evidence. */
|
||||
type FirmwareReportState =
|
||||
| "missing"
|
||||
| "empty"
|
||||
| "oversized"
|
||||
| "invalid_json"
|
||||
| "not_regular_file";
|
||||
|
||||
const firmwareReportFailure = (
|
||||
operation: string,
|
||||
state: FirmwareReportState,
|
||||
reason: string,
|
||||
cause?: unknown,
|
||||
) =>
|
||||
new AnalysisOutputError(operation, reason, {
|
||||
cause,
|
||||
diagnostics: { report_state: state },
|
||||
});
|
||||
|
||||
/** Preserve admission semantics inside the same exact owner's physical outcome. */
|
||||
const firmwareAdmissionFailure = (
|
||||
cause: unknown,
|
||||
|
||||
@@ -730,7 +730,7 @@ it("retains startup stderr when Unblob exits 1 without a report", async () => {
|
||||
diagnostics: {
|
||||
exit_code: 1,
|
||||
stderr: expect.stringContaining("Landlock sandbox is not available"),
|
||||
report_failure: expect.stringContaining("ENOENT"),
|
||||
report_state: "missing",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -740,25 +740,51 @@ it("retains startup stderr when Unblob exits 1 without a report", async () => {
|
||||
await assertFirmwareCleanup(fixture.launches);
|
||||
});
|
||||
|
||||
it("distinguishes a successful process with missing output from startup failure", async () => {
|
||||
const fixture = await firmwareFixture("missing-report");
|
||||
const result = await fixture.service.execute("extract_firmware", {
|
||||
path: fixture.path,
|
||||
output_directory: fixture.output,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisOutputError",
|
||||
reason: expect.stringContaining("expected complete report"),
|
||||
},
|
||||
});
|
||||
if (result.ok) throw new Error("Expected missing-output failure");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: { reason: expect.stringContaining("Producer forgot its report") },
|
||||
});
|
||||
await assertFirmwareCleanup(fixture.launches);
|
||||
});
|
||||
it.each([
|
||||
{ operation: "inspect_firmware_regions", engine: "binwalk" },
|
||||
{ operation: "extract_firmware", engine: "unblob" },
|
||||
] as const)(
|
||||
"keeps $engine's missing report after exit 0 distinct from an empty result",
|
||||
async ({ operation, engine }) => {
|
||||
const fixture = await firmwareFixture("missing-report");
|
||||
const result = await fixture.service.execute(operation, {
|
||||
path: fixture.path,
|
||||
...(operation === "extract_firmware"
|
||||
? { output_directory: fixture.output }
|
||||
: {}),
|
||||
});
|
||||
if (result.ok) throw new Error("Expected missing-report failure");
|
||||
const error = {
|
||||
code: "unreadable_output",
|
||||
retryable: false,
|
||||
message: expect.stringContaining("not an empty or complete result"),
|
||||
details: {
|
||||
reason: `${engine} exited 0 without a usable report: Report was not created`,
|
||||
diagnostics: {
|
||||
report_state: "missing",
|
||||
report_path: expect.stringMatching(/report\.json$/),
|
||||
command: expect.any(String),
|
||||
args: expect.any(Array),
|
||||
exit_code: 0,
|
||||
signal: null,
|
||||
stderr: "Producer forgot its report\n",
|
||||
},
|
||||
},
|
||||
remediation: {
|
||||
action: expect.stringContaining("did not retry or substitute"),
|
||||
},
|
||||
};
|
||||
expect(projectAnalysisError(result.error)).toMatchObject(error);
|
||||
expect(projectAnalysisError(result.error).message).not.toContain(
|
||||
"rea doctor",
|
||||
);
|
||||
expect(
|
||||
delivery.toCallToolResult(result, toolContract(operation))
|
||||
.structuredContent,
|
||||
).toMatchObject({ error });
|
||||
await assertFirmwareCleanup(fixture.launches);
|
||||
},
|
||||
);
|
||||
|
||||
it("cancels an owned active process and permits the next queued operation", async () => {
|
||||
const fixture = await firmwareFixture("stall");
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
inventoryFirmwareOutput,
|
||||
FirmwareFileAccess,
|
||||
} from "../../../src/firmware/FirmwareFiles.js";
|
||||
import { readFirmwareCommandReport } from "../../../src/firmware/FirmwareCommand.js";
|
||||
import { FIRMWARE_LIMITS } from "../../../src/firmware/FirmwareRelease.js";
|
||||
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it.each([
|
||||
@@ -35,6 +39,78 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["missing", "Report was not created", async () => {}],
|
||||
["empty", "Report is empty", (path: string) => writeFile(path, "")],
|
||||
[
|
||||
"invalid_json",
|
||||
"Tool report is not valid complete JSON",
|
||||
(path: string) => writeFile(path, '[{"Analysis":'),
|
||||
],
|
||||
[
|
||||
"oversized",
|
||||
`Report exceeded ${FIRMWARE_LIMITS.reportBytes} bytes`,
|
||||
(path: string) =>
|
||||
writeFile(path, Buffer.alloc(FIRMWARE_LIMITS.reportBytes + 1, 0x20)),
|
||||
],
|
||||
[
|
||||
"not_regular_file",
|
||||
"Report is not a regular file",
|
||||
(path: string) => mkdir(path),
|
||||
],
|
||||
] as const)(
|
||||
"keeps report state %s after a successful exit distinct from an empty scan",
|
||||
async (state, reason, prepare) => {
|
||||
const root = await createTestTempDirectory("rea-firmware-report-");
|
||||
const path = join(root, "report.json");
|
||||
await prepare(path);
|
||||
const failure: unknown = await readFirmwareCommandReport({
|
||||
path,
|
||||
engine: "binwalk",
|
||||
operation: "inspect_firmware_regions",
|
||||
execution: {
|
||||
stdout: { text: "", bytes: 0, observedBytes: 0 },
|
||||
stderr: { text: "", bytes: 0, observedBytes: 0 },
|
||||
exit_code: 0,
|
||||
signal: null,
|
||||
command: "/opt/binwalk",
|
||||
args: ["--quiet", "--log", path, "/work/firmware.bin"],
|
||||
resource_limiter: "/usr/bin/prlimit",
|
||||
},
|
||||
files: new FirmwareFileAccess(),
|
||||
}).catch((cause: unknown) => cause);
|
||||
if (!(failure instanceof AnalysisError))
|
||||
throw new Error("Expected a typed report failure");
|
||||
const projected = projectAnalysisError(failure);
|
||||
expect(projected).toMatchObject({
|
||||
code: "unreadable_output",
|
||||
retryable: false,
|
||||
message: expect.stringContaining("not an empty or complete result"),
|
||||
details: {
|
||||
reason: `binwalk exited 0 without a usable report: ${reason}`,
|
||||
diagnostics: {
|
||||
report_state: state,
|
||||
report_path: path,
|
||||
command: "/opt/binwalk",
|
||||
args: ["--quiet", "--log", path, "/work/firmware.bin"],
|
||||
resource_limiter: "/usr/bin/prlimit",
|
||||
exit_code: 0,
|
||||
signal: null,
|
||||
stdout_bytes: 0,
|
||||
stderr_bytes: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Generic doctor advice is not presented as a repair for a missing report.
|
||||
expect(projected.message).not.toContain("rea doctor");
|
||||
// Recorded arguments name a removed private workspace; recovery must not
|
||||
// present them as a replayable command.
|
||||
expect(projected.remediation.action).toMatch(
|
||||
/diagnostics\.report_state.*cannot be replayed as-is.*replacing every private path/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves valid Unicode and a literal replacement character in producer JSON", async () => {
|
||||
const root = await createTestTempDirectory("rea-firmware-report-");
|
||||
const path = join(root, "report.json");
|
||||
|
||||
Reference in new issue
Block a user