From 874a19418c9bb5ffdd2b245af328bcc7bd182443 Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 21 Aug 2026 14:44:26 -0400 Subject: [PATCH] fix(project): validate runtimeVersion on CodeZip runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CDK construct library rejects a CodeZip runtime that declares no runtimeVersion, but our own ProjectRuntimeSchema marked the field plain `.optional()`. The CLI therefore accepted a spec that synthesis would refuse, and the user only found out after a compile and a synth. Mirror the library's rule so the CLI reports it against agentcore.json instead. Container builds take their version from the image and stay exempt, matching the adjacent container-only-fields check. Surfacing the rule earlier is only useful if it says what to fix, and DeserializationError named the file and nothing else -- the zod issues were reachable only from the debug log. It now takes an optional `detail`, which json.read() fills with z.prettifyError(), so every caller of json.read() reports the failing field rather than just the project manager: Failed to deserialize file at ".../agentcore/agentcore.json" ✖ runtimeVersion is required for CodeZip builds → at runtimes[0].runtimeVersion A JSON syntax error carries no zod issues to render and is unchanged. Two test fixtures were CodeZip runtimes without runtimeVersion and are now invalid by this rule, so they gain one. --- src/core/project/manager.test.ts | 25 +++++++++++++++++++++++++ src/errors/errors.tsx | 14 ++++++++++++-- src/io/json.ts | 6 +++++- src/projectSchemas/project.test.ts | 1 + src/projectSchemas/runtime.test.ts | 10 ++++++++++ src/projectSchemas/runtime.ts | 11 +++++++++++ 6 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 4645dab98..d9254ca07 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -333,4 +333,29 @@ describe("FsProjectManager.resolve", () => { DeserializationError, ); }); + + test("names the offending field when the spec fails validation", async () => { + const root = await inTempDirectory(); + await mkdir(join(root, "agentcore"), { recursive: true }); + // Valid JSON, invalid spec: a CodeZip runtime with no runtimeVersion. + await writeFile( + join(root, "agentcore", "agentcore.json"), + JSON.stringify({ + name: "example", + version: 1, + runtimes: [ + { + name: "hello_world", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/hello-world", + }, + ], + }), + ); + + await expect(manager().manager.resolve({ filePath: root })).rejects.toThrow( + "runtimeVersion is required for CodeZip builds", + ); + }); }); diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index 9d973a6f8..3f7523267 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -108,8 +108,18 @@ export class SourceResolutionError extends InputValidationError { } export class DeserializationError extends AgentCoreCLIError { - constructor(path: string, options?: Omit) { - super(`Failed to deserialize file at "${path}"`, { ...options, source: ERROR_SOURCE.USER }); + constructor( + path: string, + options?: Omit & { + /** Rendered reasons the payload was rejected, appended so the user sees which field to fix. */ + detail?: string; + }, + ) { + const detail = options?.detail ? `\n${options.detail}` : ""; + super(`Failed to deserialize file at "${path}"${detail}`, { + ...options, + source: ERROR_SOURCE.USER, + }); this.name = "DeserializationError"; } } diff --git a/src/io/json.ts b/src/io/json.ts index 082f72b71..064b1481c 100644 --- a/src/io/json.ts +++ b/src/io/json.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import type z from "zod"; +import { prettifyError } from "zod"; import { DeserializationError } from "../errors"; import type { Logger } from "../logging"; @@ -52,7 +53,10 @@ export class FsReadWriteJson implements ReadWriteJson { errorMessage: parseResult.error.message, }) .error(`failed to validate parsed json file`); - throw new DeserializationError(filePath, { cause: parseResult.error }); + throw new DeserializationError(filePath, { + cause: parseResult.error, + detail: prettifyError(parseResult.error), + }); } return parseResult.data; diff --git a/src/projectSchemas/project.test.ts b/src/projectSchemas/project.test.ts index 123e9a0c9..8a9b89842 100644 --- a/src/projectSchemas/project.test.ts +++ b/src/projectSchemas/project.test.ts @@ -8,6 +8,7 @@ const runtime = { build: "CodeZip" as const, entrypoint: "main.py", codeLocation: "./agent", + runtimeVersion: "PYTHON_3_12" as const, endpoints: { LIVE: { version: 1 } }, }; diff --git a/src/projectSchemas/runtime.test.ts b/src/projectSchemas/runtime.test.ts index 5af245552..d752c2f4d 100644 --- a/src/projectSchemas/runtime.test.ts +++ b/src/projectSchemas/runtime.test.ts @@ -11,6 +11,7 @@ const codeZipAgent = { build: "CodeZip" as const, entrypoint: "main.py", codeLocation: "./agent", + runtimeVersion: "PYTHON_3_12" as const, }; const containerAgent = { name: "agent", @@ -72,6 +73,15 @@ describe("runtime custom validation", () => { }).success, ).toBe(false); }); + it("requires runtimeVersion for CodeZip builds only", () => { + const { runtimeVersion: _omitted, ...withoutVersion } = codeZipAgent; + const result = ProjectRuntimeSchema.safeParse(withoutVersion); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path).toEqual(["runtimeVersion"]); + + // Container builds take their version from the image. + expect(ProjectRuntimeSchema.safeParse(containerAgent).success).toBe(true); + }); it("restricts container-only fields to container builds", () => { for (const field of [ { dockerfile: "Dockerfile" }, diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index 61dc95886..f168ab480 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -308,6 +308,17 @@ export const ProjectRuntimeSchema = z path: ["authorizerConfiguration"], }); } + // Mirrors the CDK construct library, which rejects a CodeZip runtime with no + // runtimeVersion: it is the field that selects the packager. Validating it here + // means the CLI reports it against agentcore.json instead of letting synthesis + // fail later with the same rule. + if (data.build !== "Container" && !data.runtimeVersion) { + ctx.addIssue({ + code: "custom", + message: "runtimeVersion is required for CodeZip builds", + path: ["runtimeVersion"], + }); + } for (const field of ["dockerfile", "buildContextPath", "customDockerBuildArgs"] as const) { if (data.build !== "Container" && data[field]) { ctx.addIssue({