diff --git a/src/application/environment/environmentBundleTool.ts b/src/application/environment/environmentBundleTool.ts index 87b4352e16..bdcf75bd24 100644 --- a/src/application/environment/environmentBundleTool.ts +++ b/src/application/environment/environmentBundleTool.ts @@ -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; diff --git a/src/application/environment/environmentExport.ts b/src/application/environment/environmentExport.ts index 2c224433b3..ac7b48bb9c 100644 --- a/src/application/environment/environmentExport.ts +++ b/src/application/environment/environmentExport.ts @@ -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> => { + const operation = context.operation ?? ENVIRONMENT_EXPORT_OPERATION; const refusal = headroomRefusal(operation, path, bytes.length); if (refusal !== undefined) return err(refusal); let parsed: ReturnType; @@ -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> => { 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. */ diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index d893cd0b61..79560daef1 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -429,6 +429,7 @@ const buildEnvironmentCommand = async ( "evidence", capturePath, ENVIRONMENT_BUILD_OPERATION, + ["capture"], ); if (!read.ok) return cliError(read.error); capture = read.value; diff --git a/tests/boundary/cli/environmentBuildCli.test.ts b/tests/boundary/cli/environmentBuildCli.test.ts index 3a3de02e6c..712b49edc6 100644 --- a/tests/boundary/cli/environmentBuildCli.test.ts +++ b/tests/boundary/cli/environmentBuildCli.test.ts @@ -25,6 +25,25 @@ const listFiles = async (root: string): Promise => ) .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 }) => { diff --git a/tests/boundary/mcp/environmentBundleMcp.test.ts b/tests/boundary/mcp/environmentBundleMcp.test.ts index c79ba1e11b..bf967b4fe1 100644 --- a/tests/boundary/mcp/environmentBundleMcp.test.ts +++ b/tests/boundary/mcp/environmentBundleMcp.test.ts @@ -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( diff --git a/tests/process-global/environmentExportMemory.test.ts b/tests/process-global/environmentExportMemory.test.ts index cae4f99a05..65671ec635 100644 --- a/tests/process-global/environmentExportMemory.test.ts +++ b/tests/process-global/environmentExportMemory.test.ts @@ -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",