Skip to content

Commit 1ac4641

Browse files
committed
fix(project): validate runtimeVersion on CodeZip runtimes
The CDK construct library rejects a CodeZip runtime that declares no runtimeVersion, but our own AgentEnvSpecSchema 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 `resolve()` was collapsing every validation failure into "invalid project configuration at <path>" with the reasons reachable only from the debug log. It now renders the failing paths, so the message reads: invalid project configuration at .../agentcore/agentcore.json - runtimes[0].runtimeVersion: runtimeVersion is required for CodeZip builds A JSON syntax error has no zod issues to render and is unchanged.
1 parent 05c244e commit 1ac4641

5 files changed

Lines changed: 74 additions & 3 deletions

File tree

src/core/project/manager.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,4 +330,29 @@ describe("FsProjectManager.resolve", () => {
330330
InputValidationError,
331331
);
332332
});
333+
334+
test("names the offending field when the spec fails validation", async () => {
335+
const root = await inTempDirectory();
336+
await mkdir(join(root, "agentcore"), { recursive: true });
337+
// Valid JSON, invalid spec: a CodeZip runtime with no runtimeVersion.
338+
await writeFile(
339+
join(root, "agentcore", "agentcore.json"),
340+
JSON.stringify({
341+
name: "example",
342+
version: 1,
343+
runtimes: [
344+
{
345+
name: "hello_world",
346+
build: "CodeZip",
347+
entrypoint: "main.py",
348+
codeLocation: "app/hello-world",
349+
},
350+
],
351+
}),
352+
);
353+
354+
await expect(manager().manager.resolve({ filePath: root })).rejects.toThrow(
355+
"runtimes[0].runtimeVersion: runtimeVersion is required for CodeZip builds",
356+
);
357+
});
333358
});

src/core/project/manager.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,29 @@ import { createProjectTreeFromTemplate, TEMPLATES } from "./templates";
2020
import { ProjectSpecSchema } from "../../projectSchemas/project";
2121
import { enclosingProjectRoot } from "./fsUtils";
2222
import { DeserializationError, InputValidationError, ProjectStateError } from "../../errors/errors";
23+
import { z } from "zod";
24+
25+
/**
26+
* Renders the reasons a spec failed validation, as `runtimes[0].runtimeVersion: <why>`.
27+
* Without this the cause is only reachable from the debug log, leaving the user with
28+
* "invalid project configuration" and no indication of which field to fix.
29+
*/
30+
function describeValidationFailure(cause: unknown): string {
31+
if (!(cause instanceof z.ZodError)) return "";
32+
return cause.issues
33+
.map((issue) => {
34+
const field = issue.path.reduce<string>(
35+
(rendered, segment) =>
36+
typeof segment === "number" ? `${rendered}[${segment}]` : appendKey(rendered, segment),
37+
"",
38+
);
39+
return `\n - ${field === "" ? "(root)" : field}: ${issue.message}`;
40+
})
41+
.join("");
42+
}
43+
44+
const appendKey = (rendered: string, segment: PropertyKey): string =>
45+
rendered === "" ? String(segment) : `${rendered}.${String(segment)}`;
2346

2447
type ProjectManagerConfig = {
2548
logger: Logger;
@@ -63,9 +86,10 @@ export class FsProjectManager implements ProjectManager {
6386
} catch (error) {
6487
// A malformed agentcore.json is a user-correctable problem, not a crash.
6588
if (error instanceof DeserializationError) {
66-
throw new InputValidationError(`invalid project configuration at ${configPath}`, {
67-
cause: error,
68-
});
89+
throw new InputValidationError(
90+
`invalid project configuration at ${configPath}${describeValidationFailure(error.cause)}`,
91+
{ cause: error },
92+
);
6993
}
7094
throw error;
7195
}

src/projectSchemas/project.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const runtime = {
88
build: "CodeZip" as const,
99
entrypoint: "main.py",
1010
codeLocation: "./agent",
11+
runtimeVersion: "PYTHON_3_12" as const,
1112
endpoints: { LIVE: { version: 1 } },
1213
};
1314

src/projectSchemas/runtime.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const codeZipAgent = {
1111
build: "CodeZip" as const,
1212
entrypoint: "main.py",
1313
codeLocation: "./agent",
14+
runtimeVersion: "PYTHON_3_12" as const,
1415
};
1516
const containerAgent = {
1617
name: "agent",
@@ -72,6 +73,15 @@ describe("runtime custom validation", () => {
7273
}).success,
7374
).toBe(false);
7475
});
76+
it("requires runtimeVersion for CodeZip builds only", () => {
77+
const { runtimeVersion: _omitted, ...withoutVersion } = codeZipAgent;
78+
const result = ProjectRuntimeSchema.safeParse(withoutVersion);
79+
expect(result.success).toBe(false);
80+
expect(result.error?.issues[0]?.path).toEqual(["runtimeVersion"]);
81+
82+
// Container builds take their version from the image.
83+
expect(ProjectRuntimeSchema.safeParse(containerAgent).success).toBe(true);
84+
});
7585
it("restricts container-only fields to container builds", () => {
7686
for (const field of [
7787
{ dockerfile: "Dockerfile" },

src/projectSchemas/runtime.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,17 @@ export const ProjectRuntimeSchema = z
308308
path: ["authorizerConfiguration"],
309309
});
310310
}
311+
// Mirrors the CDK construct library, which rejects a CodeZip runtime with no
312+
// runtimeVersion: it is the field that selects the packager. Validating it here
313+
// means the CLI reports it against agentcore.json instead of letting synthesis
314+
// fail later with the same rule.
315+
if (data.build !== "Container" && !data.runtimeVersion) {
316+
ctx.addIssue({
317+
code: "custom",
318+
message: "runtimeVersion is required for CodeZip builds",
319+
path: ["runtimeVersion"],
320+
});
321+
}
311322
for (const field of ["dockerfile", "buildContextPath", "customDockerBuildArgs"] as const) {
312323
if (data.build !== "Container" && data[field]) {
313324
ctx.addIssue({

0 commit comments

Comments
 (0)