diff --git a/src/handlers/utils.test.tsx b/src/handlers/utils.test.tsx index 52ba6071b..5d931b479 100644 --- a/src/handlers/utils.test.tsx +++ b/src/handlers/utils.test.tsx @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; +import z from "zod"; import { assertMutuallyExclusiveFlags, parseJsonArrayFlag, + parseJsonFlagWithSchema, parseJsonObjectFlag, parseTags, } from "./utils"; @@ -19,6 +21,49 @@ describe("structured JSON flags", () => { }); }); +describe("parseJsonFlagWithSchema", () => { + const ModelSchema = z + .object({ modelId: z.string(), apiKeyArn: z.string().optional() }) + .refine((m) => m.modelId !== "needs-key" || m.apiKeyArn !== undefined, { + message: "apiKeyArn is required", + }); + + test("returns undefined when the flag is omitted", () => { + expect(parseJsonFlagWithSchema("model", undefined, ModelSchema)).toBeUndefined(); + }); + + test("returns valid input unchanged", () => { + expect(parseJsonFlagWithSchema("model", '{"modelId":"anthropic.x"}', ModelSchema)).toEqual({ + modelId: "anthropic.x", + }); + }); + + // The F17 case: without the strict pass this parses, drops `modlId`, and sends + // an object missing the field the user was trying to set. + test("rejects a key the schema does not declare instead of dropping it", () => { + expect(() => + parseJsonFlagWithSchema("model", '{"modelId":"anthropic.x","modlId":"typo"}', ModelSchema), + ).toThrow("model"); + }); + + test("keeps the schema's own refinements", () => { + expect(() => parseJsonFlagWithSchema("model", '{"modelId":"needs-key"}', ModelSchema)).toThrow( + "apiKeyArn is required", + ); + }); + + // A record accepts any key by definition, so there is nothing to reject and the + // strict pass must leave it alone. + test("leaves a record schema permissive", () => { + const tags = parseJsonFlagWithSchema( + "tags", + '{"env":"prod"}', + z.record(z.string(), z.string()), + ); + expect(tags).toEqual({ env: "prod" }); + }); +}); + describe("parseTags", () => { test("returns undefined for undefined input", () => { expect(parseTags(undefined)).toBeUndefined(); diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 82d18ae39..fda58f136 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useEffect } from "react"; import type { Context } from "../router"; -import type z from "zod"; +import z from "zod"; import type { CoreOptions } from "../core/types"; import type { AppIO } from "../io"; import { AgentCoreCLIError, InputValidationError, SilentCLIError } from "../errors"; @@ -69,7 +69,7 @@ export function parseJsonFlagWithSchema( const parsed = parseJsonFlag(name, raw); if (parsed === undefined) return undefined; - const result = schema.safeParse(parsed); + const result = strictened(schema).safeParse(parsed); if (!result.success) { throw new InputValidationError( `Invalid value for option '--${name}': ${formatZodError(result.error)}`, @@ -79,6 +79,11 @@ export function parseJsonFlagWithSchema( return result.data; } +// Top level only: a nested typo (model.bedrockModelConfig.modelIdd) still slips through. +function strictened(schema: z.ZodType): z.ZodType { + return schema instanceof z.ZodObject ? (schema.strict() as unknown as z.ZodType) : schema; +} + export function parseJsonObjectFlag(name: string, raw: string): T; export function parseJsonObjectFlag( name: string,