fix(environment)!: attribute source errors to caller fields (#7900)

* fix(environment): attribute source file errors to caller fields

* style(environment): format source admission tests
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 08:12:09 +08:00
1 parent c7722c8eeb
commit 5147b6e306
6 files changed
+73 -13

No files matched your search

@@ -33,6 +33,7 @@ const captureFile = async (
"evidence",
source.path,
EXPORT_ENVIRONMENT_BUNDLE_OPERATION,
["source", "path"],
);
const retained = resolveEvidenceInput(source, lookup);
if (!retained.ok) return retained;
@@ -243,7 +243,7 @@ export const environmentExportEvidenceFile = async (
const refused = headroomRefusal(operation, path, size);
if (refused !== undefined) throw refused;
});
return await environmentExportFile(role, path, bytes, operation);
return await environmentExportFile(role, path, bytes, { operation });
} catch (cause: unknown) {
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
throw cause;
@@ -255,8 +255,12 @@ export const environmentExportFile = async (
role: "manifest" | "evidence",
path: string,
bytes: Buffer,
operation: string = ENVIRONMENT_EXPORT_OPERATION,
context: {
operation?: string;
fieldPath?: AnalysisInputIssue["path"] | undefined;
} = {},
): Promise<Result<EnvironmentExportFile, AnalysisError>> => {
const operation = context.operation ?? ENVIRONMENT_EXPORT_OPERATION;
const refusal = headroomRefusal(operation, path, bytes.length);
if (refusal !== undefined) return err(refusal);
let parsed: ReturnType<typeof parseUtf8Json>;
@@ -271,7 +275,7 @@ export const environmentExportFile = async (
inputError(
operation,
{
path: [role],
path: context.fieldPath ?? [role],
reason: "invalid_format",
expected: "JSON",
message: parsed.error,
@@ -289,6 +293,7 @@ export const readEnvironmentExportFile = async (
role: "manifest" | "evidence",
path: string,
operation: string = ENVIRONMENT_EXPORT_OPERATION,
fieldPath?: AnalysisInputIssue["path"],
): Promise<Result<EnvironmentExportFile, AnalysisError>> => {
const resources = new ArtifactResourceScope();
let bytes: Buffer;
@@ -336,7 +341,7 @@ export const readEnvironmentExportFile = async (
inputError(
operation,
{
path: [role],
path: fieldPath ?? [role],
reason: "invalid_value",
expected: "one stable regular file snapshot",
message: `The ${role} file changed while it was read.`,
@@ -349,9 +354,12 @@ export const readEnvironmentExportFile = async (
inputError(
operation,
{
path: [role],
path: fieldPath ?? [role],
reason: "invalid_value",
message: `The ${role} argument must be a readable file path; inline JSON cannot be exported.`,
message:
fieldPath === undefined
? `The ${role} argument must be a readable file path; inline JSON cannot be exported.`
: "The selected source path must be a readable regular file.",
},
`${path} could not be read as the ${role} file.`,
),
@@ -360,7 +368,7 @@ export const readEnvironmentExportFile = async (
if (denied !== undefined) return err(denied);
throw cause;
}
return environmentExportFile(role, path, bytes, operation);
return environmentExportFile(role, path, bytes, { operation, fieldPath });
};
/** Read both files once; the caller authenticates the parsed values and the export copies the same bytes. */
+1
View File
@@ -429,6 +429,7 @@ const buildEnvironmentCommand = async (
"evidence",
capturePath,
ENVIRONMENT_BUILD_OPERATION,
["capture"],
);
if (!read.ok) return cliError(read.error);
capture = read.value;
@@ -25,6 +25,25 @@ const listFiles = async (root: string): Promise<string[]> =>
)
.sort();
cliTest(
"attributes invalid capture JSON to the capture argument",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-environment-invalid-capture-",
);
const capture = join(root, "invalid.json");
await writeFile(capture, "{");
const result = await cli.run({
arguments: ["environment-build", capture, join(root, "output"), "--json"],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: { issues: [{ path: ["capture"], reason: "invalid_format" }] },
});
},
);
cliTest(
"builds the bundle the three CLI steps build, byte for byte",
async ({ cli }) => {
@@ -202,6 +202,40 @@ it("copies a capture Evidence file byte for byte and plans without writing on a
).toBe(0);
});
it("attributes source file admission and JSON failures to source.path", async () => {
const { client } = await connect();
const root = await createTestTempDirectory("rea-bundle-source-errors-");
await mkdir(join(root, "directory"));
await writeFile(join(root, "invalid-utf8"), Buffer.from([0xff]));
await writeFile(join(root, "invalid-json"), "{");
for (const [name, reason] of [
["missing", "invalid_value"],
["directory", "invalid_value"],
["invalid-utf8", "invalid_format"],
["invalid-json", "invalid_format"],
] as const) {
const output = join(root, `out-${name}`);
const result = await client.callTool({
name: TOOL,
arguments: {
source: { kind: "evidence-file", path: join(root, name) },
output_directory: output,
dry_run: true,
},
});
const { error } = parseMcpToolError(result);
expect(error).toMatchObject({
code: "invalid_request",
recovery: { kind: "inspect_input_contract", operation: TOOL },
details: { issues: [{ path: ["source", "path"], reason }] },
});
expect(JSON.stringify(error)).not.toContain(
"inline JSON cannot be exported",
);
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
}
});
it("refuses an existing or parentless output directory and leaves it as it was", async () => {
const { client, session } = await connect();
const capture = analysisViewCaptureEvidence(
@@ -37,12 +37,9 @@ it.each(["serialize", "parse"] as const)(
capture,
"environment-build",
)
: await environmentExportFile(
"evidence",
"capture.json",
bytes,
"environment-build",
);
: await environmentExportFile("evidence", "capture.json", bytes, {
operation: "environment-build",
});
if (result.ok) throw new Error("Expected a memory refusal");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "resource_constraint",