From 8cbd4510656626624f41d2570c4d23fba0c0f563 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 11 Sep 2026 15:30:08 -0400 Subject: [PATCH] fix(cli): emit recipe-run envelope for schema rejections JSON schema validation failed before recipe-run could emit wp-codebox/recipe-run/v1, so consumers saw cli-failure/v1 instead. Name additionalProperties in issue paths and return a recipe-run envelope that includes the rejected keys. --- packages/cli/src/commands/recipe-run.ts | 40 ++++++++++++++++- packages/runtime-core/src/recipe-schema.ts | 16 ++++++- tests/recipe-json-schema-validation.test.ts | 48 +++++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 tests/recipe-json-schema-validation.test.ts diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index c1e6f4da..2aba2916 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs" import { mkdir, readFile, writeFile } from "node:fs/promises" import { createRequire } from "node:module" import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" -import { DEFAULT_WORDPRESS_VERSION, createRuntime, normalizeRecipeRunSummary, normalizeRuntimeEnvRecord, parseCommandOptions, validateRuntimePolicy, type ArtifactBundle, type ArtifactPackageIdentity, type ArtifactPackageProvenance, type Runtime, type RuntimeAssetSpec, type RuntimePolicy, type RuntimePreviewSpec, type RuntimeRunRegistry, type WorkspaceRecipe, type WorkspaceRecipeComponentManifest, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase } from "@automattic/wp-codebox-core" +import { DEFAULT_WORDPRESS_VERSION, RecipeJsonSchemaValidationError, createRuntime, normalizeRecipeRunSummary, normalizeRuntimeEnvRecord, parseCommandOptions, validateRuntimePolicy, type ArtifactBundle, type ArtifactPackageIdentity, type ArtifactPackageProvenance, type Runtime, type RuntimeAssetSpec, type RuntimePolicy, type RuntimePreviewSpec, type RuntimeRunRegistry, type WorkspaceRecipe, type WorkspaceRecipeComponentManifest, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase } from "@automattic/wp-codebox-core" import { stripUndefined } from "@automattic/wp-codebox-core/internals" import { recipeExecutionSpec, sandboxWorkspaceContract } from "../agent-sandbox.js" import { captureStdout, printRecipeHumanOutput, printRecipeValidateHumanOutput, serializeError } from "../output.js" @@ -42,7 +42,19 @@ const SUCCESSFUL_RECIPE_RUNTIME_SNAPSHOT_TIMEOUT_MS = 120 * 1000 const packageRequire = createRequire(import.meta.url) export async function runRecipeRunCommand(args: string[]): Promise { const options = parseRecipeRunOptions(args) - const replayExitCode = await replayWithHostNodeHeap(args, options.hostNodeHeapMiB, (await loadWorkspaceRecipe(options.recipePath)).runtime?.hostNodeHeap) + let recipe: WorkspaceRecipe + try { + recipe = await loadWorkspaceRecipe(options.recipePath) + } catch (error) { + if (!isRecipeJsonSchemaValidationError(error)) { + throw error + } + const output = recipeJsonSchemaValidationOutput(options, error) + if (options.json) await writeRecipeJsonOutput(output, options.outputPath) + else printRecipeHumanOutput(output) + return 1 + } + const replayExitCode = await replayWithHostNodeHeap(args, options.hostNodeHeapMiB, recipe.runtime?.hostNodeHeap) if (replayExitCode !== undefined) return replayExitCode if (options.previewLeaseRequested && !options.previewLeaseChild) { return startPreviewLeaseRecipeRun({ args, json: options.json, recipePath: options.recipePath, artifactsDirectory: options.artifactsDirectory, runRegistryDirectory: options.runRegistryDirectory, previewHoldSeconds: options.previewHoldSeconds }) @@ -90,6 +102,30 @@ export async function runRecipeRunCommand(args: string[]): Promise { } } +function isRecipeJsonSchemaValidationError(error: unknown): error is RecipeJsonSchemaValidationError { + return error instanceof RecipeJsonSchemaValidationError + || (error instanceof Error && (error.name === "RecipeJsonSchemaValidationError" || error.message.startsWith("Recipe JSON schema validation failed"))) +} + +function recipeJsonSchemaValidationOutput(options: RecipeRunOptions, error: RecipeJsonSchemaValidationError | Error): RecipeRunOutput { + const issues = error instanceof RecipeJsonSchemaValidationError + ? error.issues.map((issue) => ({ code: issue.keyword, path: issue.path, message: issue.message })) + : [] + return { + success: false, + schema: "wp-codebox/recipe-run/v1", + recipePath: options.recipePath, + executions: [], + validation: { issues }, + error: { + ...serializeError(error), + name: "RecipeJsonSchemaValidationError", + code: "recipe-json-schema-validation-failed", + issues, + }, + } +} + export function createRecipeRunOptions(options: RecipeRunOptionsInput): RecipeRunOptions { return { previewHoldBlocking: false, diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index 8dcc6e56..15fd781f 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -34,6 +34,17 @@ export interface AssertWorkspaceRecipeJsonSchemaOptions extends WorkspaceRecipeJ recipePath?: string } +export class RecipeJsonSchemaValidationError extends Error { + readonly code = "recipe-json-schema-validation-failed" + readonly issues: WorkspaceRecipeJsonSchemaValidationIssue[] + + constructor(message: string, issues: WorkspaceRecipeJsonSchemaValidationIssue[]) { + super(message) + this.name = "RecipeJsonSchemaValidationError" + this.issues = issues + } +} + export type WorkspaceRecipeRuntimeCollectedArtifact = | { kind: "path"; index: number; artifact: WorkspaceRecipeDeclaredArtifact } | { kind: "typed"; index: number; artifact: WorkspaceRecipeTypedArtifact } @@ -60,7 +71,7 @@ export function assertWorkspaceRecipeJsonSchema(recipe: unknown, options: Assert const location = options.recipePath ? ` in ${options.recipePath}` : "" const details = result.issues.map((issue) => `${issue.path} ${issue.message}`).join("; ") - throw new Error(`Recipe JSON schema validation failed${location}: ${details}`) + throw new RecipeJsonSchemaValidationError(`Recipe JSON schema validation failed${location}: ${details}`, result.issues) } export function workspaceRecipeRuntimeCollectedArtifacts(recipe: WorkspaceRecipe): WorkspaceRecipeRuntimeCollectedArtifact[] { @@ -84,6 +95,9 @@ function jsonPointerToJsonPath(pointer: string, error: ErrorObject): string { if (error.keyword === "required" && typeof error.params.missingProperty === "string") { segments.push(error.params.missingProperty) } + if ((error.keyword === "additionalProperties" || error.keyword === "unevaluatedProperties") && typeof error.params.additionalProperty === "string") { + segments.push(error.params.additionalProperty) + } let path = "$" for (const segment of segments) { path += /^\d+$/.test(segment) ? `[${segment}]` : `.${segment}` diff --git a/tests/recipe-json-schema-validation.test.ts b/tests/recipe-json-schema-validation.test.ts new file mode 100644 index 00000000..964bb342 --- /dev/null +++ b/tests/recipe-json-schema-validation.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict" +import { writeFile } from "node:fs/promises" +import { join } from "node:path" + +import { captureStdout } from "../packages/cli/src/output.js" +import { runRecipeRunCommand } from "../packages/cli/src/commands/recipe-run.js" +import { RecipeJsonSchemaValidationError, validateWorkspaceRecipeJsonSchema } from "../packages/runtime-core/src/index.js" +import { withTempDir } from "../scripts/test-kit.js" + +const extraKeyRecipe = { + schema: "wp-codebox/workspace-recipe/v1", + inputs: { + services: [{ + id: "mysql", + kind: "mysql", + configuration: { rootAuthentication: "empty-password", unexpectedFlag: true }, + outputs: { host: "DB_HOST", port: "DB_PORT" }, + }], + }, + workflow: { steps: [{ command: "wordpress.run-php" }] }, +} + +const extraKeyResult = validateWorkspaceRecipeJsonSchema(extraKeyRecipe) +assert.equal(extraKeyResult.valid, false) +assert.ok(extraKeyResult.issues.some((issue) => issue.path === "$.inputs.services[0].configuration.unexpectedFlag"), extraKeyResult.issues.map((issue) => issue.path).join("; ")) + +assert.throws( + () => { + throw new RecipeJsonSchemaValidationError("Recipe JSON schema validation failed: $.inputs.services[0].configuration.unexpectedFlag must NOT have additional properties", extraKeyResult.issues) + }, + (error: unknown) => error instanceof RecipeJsonSchemaValidationError && error.code === "recipe-json-schema-validation-failed", +) + +await withTempDir("wp-codebox-recipe-schema-failure-envelope-", async (directory) => { + const recipePath = join(directory, "recipe.json") + await writeFile(recipePath, `${JSON.stringify(extraKeyRecipe, null, 2)}\n`) + const { result: exitCode, logs } = await captureStdout(async () => await runRecipeRunCommand(["--recipe", recipePath, "--json"])) + assert.equal(exitCode, 1) + const output = JSON.parse(logs[0]) + assert.equal(output.schema, "wp-codebox/recipe-run/v1") + assert.equal(output.success, false) + assert.equal(output.error.code, "recipe-json-schema-validation-failed") + assert.equal(output.error.name, "RecipeJsonSchemaValidationError") + assert.match(output.error.message, /configuration\.unexpectedFlag/) + assert.ok((output.validation?.issues ?? []).some((issue: { path: string }) => issue.path === "$.inputs.services[0].configuration.unexpectedFlag")) +}) + +console.log("recipe json schema validation envelope ok")