From 1765fb9784ecd7f65bdee24539ded0125bb17e33 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 29 Aug 2026 18:47:06 -0400 Subject: [PATCH] feat(recipe): support phased plugin inputs --- bin/wp-codebox-source.mjs | 7 +- docs/recipe-contract.md | 57 ++++++ .../src/commands/recipe-declared-artifacts.ts | 4 +- .../commands/recipe-phased-plugin-input.ts | 124 ++++++++++++ .../cli/src/commands/recipe-run-output.ts | 13 +- packages/cli/src/commands/recipe-run-types.ts | 5 +- packages/cli/src/commands/recipe-run.ts | 34 +++- .../cli/src/commands/recipe-runtime-setup.ts | 176 +++++++++++++----- packages/cli/src/output.ts | 2 +- packages/cli/src/recipe-sources.ts | 68 ++++--- packages/cli/src/recipe-validation.ts | 18 ++ packages/cli/src/source-policy.ts | 4 +- packages/runtime-core/src/recipe-schema.ts | 34 ++++ .../runtime-core/src/runtime-contracts.ts | 29 +++ tests/recipe-phased-plugin-input.test.ts | 89 +++++++++ .../recipe-run-artifacts-mount-guard.test.ts | 8 +- tests/recipe-run-summary-output.test.ts | 8 + 17 files changed, 590 insertions(+), 90 deletions(-) create mode 100644 packages/cli/src/commands/recipe-phased-plugin-input.ts create mode 100644 tests/recipe-phased-plugin-input.test.ts diff --git a/bin/wp-codebox-source.mjs b/bin/wp-codebox-source.mjs index 891d88464..a05bac07c 100755 --- a/bin/wp-codebox-source.mjs +++ b/bin/wp-codebox-source.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process" -import { existsSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" @@ -9,6 +9,7 @@ const repoRoot = dirname(scriptDirectory) const distEntrypoint = join(repoRoot, "packages/cli/dist/index.js") const nodeModules = join(repoRoot, "node_modules") const packageLock = join(repoRoot, "package-lock.json") +const packageJson = join(repoRoot, "package.json") const callerCwd = process.cwd() function run(command, args, options = {}) { @@ -51,5 +52,7 @@ if (!existsSync(nodeModules)) { run("npm", [existsSync(packageLock) ? "ci" : "install"], { failureContext: "install source checkout dependencies" }) } -run("npm", ["run", "build"], { failureContext: "build the source checkout" }) +const scripts = JSON.parse(readFileSync(packageJson, "utf8")).scripts ?? {} +const buildScript = distIsAbsent && scripts["build:release"] ? "build:release" : "build" +run("npm", ["run", buildScript], { failureContext: "build the source checkout" }) run(process.execPath, [distEntrypoint, ...process.argv.slice(2)], { cwd: callerCwd, delegate: true }) diff --git a/docs/recipe-contract.md b/docs/recipe-contract.md index daddc48e3..00f39515d 100644 --- a/docs/recipe-contract.md +++ b/docs/recipe-contract.md @@ -361,6 +361,63 @@ Use `allowFailure: true` or `advisory: true` for evidence-only workflow steps. Failed advisory steps are reported in `advisoryFailures` and do not make an otherwise successful recipe return `success: false`. +### Phased Plugin Inputs + +A required workflow step may declare `pluginInput` to consume one named artifact +from `artifacts.typed` before later steps continue. WP Codebox collects the JSON +inside the live runtime, applies the declared JSON Pointer projection, resolves +packages on the host, mounts and activates them in the same runtime, verifies +plugin readiness, and then resumes the workflow. + +```json +{ + "workflow": { + "steps": [ + { + "command": "wordpress.wp-cli", + "args": ["command=example plan --output=/tmp/provider-plan.json"], + "pluginInput": { + "artifact": "provider-plan", + "packages": { + "resolver": "wordpress.org-latest-stable", + "items": "/entries", + "map": { + "slug": "/slug", + "pluginFile": "/plugin_entrypoint" + } + } + } + }, + { "command": "wordpress.wp-cli", "args": ["command=example import"] } + ] + }, + "artifacts": { + "typed": [ + { + "name": "provider-plan", + "type": "example/provider-plan", + "path": "/tmp/provider-plan.json", + "required": true, + "parseJson": true, + "contentType": "application/json", + "payloadSchema": "example/provider-plan/v1" + } + ] + } +} +``` + +`immutable-archive` is the default resolver and requires `source`, `sha256`, and +`slug` pointer mappings. `wordpress.org-latest-stable` accepts `slug` and an +optional `pluginFile`, resolves the exact current version through the +WordPress.org API on the host, and accepts packages only from +`downloads.wordpress.org`. This explicit resolver authorizes that bounded host +download without enabling network access inside WordPress. Typed artifact +identity, projected package count, resolver, resolved source/version/digest, +mount, activation, readiness, and failures remain visible in recipe evidence. +Local paths, Composer execution, MU-plugin loading, ambient stdout, and inferred +key-name mappings are not accepted from phased artifacts. + ## Fuzz Case Runs Recipes may declare a bounded, deterministic fuzz case skeleton with diff --git a/packages/cli/src/commands/recipe-declared-artifacts.ts b/packages/cli/src/commands/recipe-declared-artifacts.ts index bdfbefda9..e41fc2fba 100644 --- a/packages/cli/src/commands/recipe-declared-artifacts.ts +++ b/packages/cli/src/commands/recipe-declared-artifacts.ts @@ -59,7 +59,7 @@ async function collectRecipeDeclaredArtifact(runtime: Runtime, artifact: Workspa } } -async function collectRecipeTypedArtifact(runtime: Runtime, artifact: WorkspaceRecipeTypedArtifact, index: number, effectivePath: string): Promise { +export async function collectRecipeTypedArtifact(runtime: Runtime, artifact: WorkspaceRecipeTypedArtifact, index: number, effectivePath = artifact.path, options: { redact?: boolean } = {}): Promise { try { const execution = await runtime.execute({ command: "wordpress.run-php", @@ -78,7 +78,7 @@ async function collectRecipeTypedArtifact(runtime: Runtime, artifact: WorkspaceR type: collected.type, size: collected.size, sha256: collected.sha256, - parsedJson: collected.parsedJson === undefined ? undefined : redactJsonValue(collected.parsedJson), + parsedJson: collected.parsedJson === undefined ? undefined : options.redact === false ? collected.parsedJson : redactJsonValue(collected.parsedJson), typedArtifact: { name: artifact.name, type: artifact.type, diff --git a/packages/cli/src/commands/recipe-phased-plugin-input.ts b/packages/cli/src/commands/recipe-phased-plugin-input.ts new file mode 100644 index 000000000..27f7fe77f --- /dev/null +++ b/packages/cli/src/commands/recipe-phased-plugin-input.ts @@ -0,0 +1,124 @@ +import { Buffer } from "node:buffer" +import type { ProjectedPluginPackageDescriptor, Runtime, WorkspaceRecipe, WorkspaceRecipeExtraPlugin, WorkspaceRecipePluginPackageProjection, WorkspaceRecipeStep } from "@automattic/wp-codebox-core" +import { recipeSource } from "../recipe-sources.js" +import { collectRecipeTypedArtifact } from "./recipe-declared-artifacts.js" +import type { RecipePhasedPluginInputStage, RecipeRunDeclaredArtifact } from "./recipe-run-types.js" + +const MAX_PHASED_PLUGINS = 20 +const MAX_REGISTRY_RESPONSE_BYTES = 1024 * 1024 + +export class RecipePhasedPluginInputError extends Error { + readonly code = "recipe-phased-plugin-input-failed" + + constructor(message: string, readonly stage: RecipePhasedPluginInputStage, readonly context: Record = {}, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }) + this.name = "RecipePhasedPluginInputError" + } +} + +export async function collectPhasedPluginArtifact(recipe: WorkspaceRecipe, step: WorkspaceRecipeStep, runtime: Runtime): Promise { + const artifactName = step.pluginInput?.artifact + const index = (recipe.artifacts?.typed ?? []).findIndex((artifact) => artifact.name === artifactName) + const declaration = index >= 0 ? recipe.artifacts?.typed?.[index] : undefined + if (!declaration) throw new RecipePhasedPluginInputError("Phased plugin input references an unknown typed artifact.", "collect", { artifact: artifactName }) + const artifact = await collectRecipeTypedArtifact(runtime, declaration, index, declaration.path, { redact: false }) + if (artifact.status !== "collected" || artifact.parsedJson === undefined) { + throw new RecipePhasedPluginInputError("Phased plugin input artifact could not be collected as JSON.", "collect", { artifact: artifactName, status: artifact.status }) + } + return artifact +} + +export function projectPhasedPluginPackages(input: unknown, projection: WorkspaceRecipePluginPackageProjection): ProjectedPluginPackageDescriptor[] { + const selected = pointerValue(input, projection.items) + if (!selected.found || !Array.isArray(selected.value)) throw new RecipePhasedPluginInputError("Phased plugin package items pointer must resolve to an array.", "project", { pointer: projection.items }) + if (selected.value.length > MAX_PHASED_PLUGINS) throw new RecipePhasedPluginInputError(`Phased plugin input exceeds the ${MAX_PHASED_PLUGINS}-plugin bound.`, "project") + const resolver = projection.resolver ?? "immutable-archive" + const slugs = new Set() + return selected.value.map((item, index) => { + const slug = projectedString(item, projection.map.slug, `items[${index}].slug`, true) + const source = projectedString(item, projection.map.source, `items[${index}].source`, resolver === "immutable-archive") + const sha256 = projectedString(item, projection.map.sha256, `items[${index}].sha256`, resolver === "immutable-archive") + const pluginFile = projectedString(item, projection.map.pluginFile, `items[${index}].pluginFile`, false) + const activate = projectedBoolean(item, projection.map.activate, `items[${index}].activate`) + const loadAs = projectedString(item, projection.map.loadAs, `items[${index}].loadAs`, false) + if (!/^[a-z0-9][a-z0-9-_]*$/i.test(slug)) throw new RecipePhasedPluginInputError(`items[${index}].slug is invalid.`, "project", { slug }) + if (slugs.has(slug)) throw new RecipePhasedPluginInputError(`items[${index}].slug duplicates ${slug}.`, "project", { slug }) + if (sha256 && !/^[a-f0-9]{64}$/i.test(sha256)) throw new RecipePhasedPluginInputError(`items[${index}].sha256 must be a 64-character hexadecimal digest.`, "project") + if (pluginFile && (!pluginFile.startsWith(`${slug}/`) || !/^[^/][^:]*\.php$/.test(pluginFile) || pluginFile.includes(".."))) throw new RecipePhasedPluginInputError(`items[${index}].pluginFile must stay under ${slug}/.`, "project") + if (loadAs && loadAs !== "plugin") throw new RecipePhasedPluginInputError(`items[${index}].loadAs must be plugin.`, "project") + if (source) { + try { + if (recipeSource(source, sha256).type === "local") throw new Error("local source") + } catch (error) { + throw new RecipePhasedPluginInputError(`items[${index}].source must be an HTTPS plugin archive.`, "project", { source }, error) + } + } + slugs.add(slug) + return { slug, ...(source ? { source } : {}), ...(sha256 ? { sha256: sha256.toLowerCase() } : {}), ...(pluginFile ? { pluginFile } : {}), ...(activate !== undefined ? { activate } : {}), loadAs: "plugin" } + }) +} + +export async function resolvePhasedPluginPackages(descriptors: ProjectedPluginPackageDescriptor[], projection: WorkspaceRecipePluginPackageProjection, fetcher = fetch): Promise { + const resolver = projection.resolver ?? "immutable-archive" + if (resolver === "immutable-archive") return descriptors.map((descriptor) => ({ ...descriptor, activate: descriptor.activate !== false })) as WorkspaceRecipeExtraPlugin[] + const plugins: WorkspaceRecipeExtraPlugin[] = [] + for (const descriptor of descriptors) { + const infoUrl = new URL("https://api.wordpress.org/plugins/info/1.2/") + infoUrl.searchParams.set("action", "plugin_information") + infoUrl.searchParams.set("request[slug]", descriptor.slug) + const response = await fetcher(infoUrl, { redirect: "error", signal: AbortSignal.timeout(30_000) }) + if (!response.ok || !/^application\/json\b/i.test(response.headers.get("content-type") || "")) throw new RecipePhasedPluginInputError(`WordPress.org plugin info failed for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug, status: response.status }) + const contentLength = Number(response.headers.get("content-length") || 0) + if (contentLength > MAX_REGISTRY_RESPONSE_BYTES) throw new RecipePhasedPluginInputError(`WordPress.org plugin info exceeded the response bound for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug, content_length: contentLength }) + const responseText = await response.text() + if (Buffer.byteLength(responseText) > MAX_REGISTRY_RESPONSE_BYTES) throw new RecipePhasedPluginInputError(`WordPress.org plugin info exceeded the response bound for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug }) + let info: { version?: unknown; download_link?: unknown } + try { info = JSON.parse(responseText) as typeof info } catch (error) { throw new RecipePhasedPluginInputError(`WordPress.org plugin info was malformed for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug }, error) } + const version = typeof info.version === "string" ? info.version : "" + const source = typeof info.download_link === "string" ? info.download_link : "" + let sourceUrl: URL + try { sourceUrl = new URL(source) } catch (error) { throw new RecipePhasedPluginInputError(`WordPress.org returned an invalid package URL for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug }, error) } + if (sourceUrl.protocol !== "https:" || sourceUrl.hostname !== "downloads.wordpress.org" || !sourceUrl.pathname.startsWith("/plugin/") || !version || !/^\d[0-9A-Za-z._+-]*$/.test(version)) throw new RecipePhasedPluginInputError(`WordPress.org returned an invalid immutable package for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug, version, source }) + plugins.push({ + source: sourceUrl.toString(), + slug: descriptor.slug, + ...(descriptor.pluginFile ? { pluginFile: descriptor.pluginFile } : {}), + activate: descriptor.activate !== false, + loadAs: "plugin", + metadata: { phased_input: { resolver, version, source_url: sourceUrl.toString() } }, + }) + } + return plugins +} + +function projectedString(item: unknown, pointer: string | undefined, field: string, required: boolean): string { + if (!pointer) { + if (required) throw new RecipePhasedPluginInputError(`${field} mapping is required.`, "project") + return "" + } + const selected = pointerValue(item, pointer) + if (!selected.found || typeof selected.value !== "string" || (required && !selected.value)) throw new RecipePhasedPluginInputError(`${field} must resolve to${required ? " a non-empty" : ""} string.`, "project", { pointer }) + return selected.value +} + +function projectedBoolean(item: unknown, pointer: string | undefined, field: string): boolean | undefined { + if (!pointer) return undefined + const selected = pointerValue(item, pointer) + if (!selected.found || typeof selected.value !== "boolean") throw new RecipePhasedPluginInputError(`${field} must resolve to a boolean.`, "project", { pointer }) + return selected.value +} + +function pointerValue(value: unknown, pointer: string): { found: boolean; value?: unknown } { + if (pointer === "") return { found: true, value } + if (!pointer.startsWith("/") || /~(?:[^01]|$)/.test(pointer)) return { found: false } + let current = value + for (const encoded of pointer.slice(1).split("/")) { + const segment = encoded.replace(/~1/g, "/").replace(/~0/g, "~") + if (Array.isArray(current)) { + if (!/^(0|[1-9]\d*)$/.test(segment) || Number(segment) >= current.length) return { found: false } + current = current[Number(segment)] + } else if (current && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, segment)) current = (current as Record)[segment] + else return { found: false } + } + return { found: true, value: current } +} diff --git a/packages/cli/src/commands/recipe-run-output.ts b/packages/cli/src/commands/recipe-run-output.ts index 8cdd50e91..3df4ca4e7 100644 --- a/packages/cli/src/commands/recipe-run-output.ts +++ b/packages/cli/src/commands/recipe-run-output.ts @@ -1,4 +1,6 @@ import { setTimeout as delay } from "node:timers/promises" +import { mkdir, writeFile } from "node:fs/promises" +import { dirname } from "node:path" import type { ExecutionResult, RecipeRunSummary, RuntimeRunRecord } from "@automattic/wp-codebox-core" import { boundedExecutionResultsForArtifacts } from "@automattic/wp-codebox-core/internals" import { serializeError } from "../output.js" @@ -95,8 +97,15 @@ function hasTerminalRecipePhaseFailure(output: RecipeRunOutput): boolean { return output.phaseEvidence?.some((phase) => phase.status === "failed" && hasSerializedErrorCode(phase.error, "recipe-phase-failed")) ?? false } -export async function writeRecipeJsonOutput(output: unknown): Promise { - await writeStdout(`${JSON.stringify(boundedRecipeJsonOutput(output), null, 2)}\n`) +export async function writeRecipeJsonOutput(output: unknown, outputPath?: string): Promise { + const json = `${JSON.stringify(boundedRecipeJsonOutput(output), null, 2)}\n` + if (outputPath) { + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, json) + return + } + + await writeStdout(json) } export function boundedRecipeJsonOutput(output: unknown): unknown { diff --git a/packages/cli/src/commands/recipe-run-types.ts b/packages/cli/src/commands/recipe-run-types.ts index 90c089dbe..624041008 100644 --- a/packages/cli/src/commands/recipe-run-types.ts +++ b/packages/cli/src/commands/recipe-run-types.ts @@ -10,6 +10,7 @@ import type { RecipeSourceProvenance } from "../recipe-sources.js" export interface RecipeRunOptions { recipePath: string + outputPath?: string artifactsDirectory?: string runRegistryDirectory?: string previewHoldSeconds?: number @@ -317,7 +318,9 @@ export interface RecipeDiagnosticArtifactRef { sha256?: string } -export type RecipePhaseName = "provision_runtime_services" | "runtime_startup" | "mount_plugins" | "activate_plugins" | "run_blueprint_steps" | "apply_distribution" | "import_fixture_databases" | "run_distribution_setup_artifacts" | "run_distribution_startup_probes" | "run_workloads" | "run_adversarial_campaigns" | "run_probes" | "collect_artifacts" +export type RecipePhasedPluginInputStage = "collect" | "project" | "resolve" | "mount" | "activate" | "readiness" + +export type RecipePhaseName = "provision_runtime_services" | "runtime_startup" | "mount_plugins" | "activate_plugins" | "collect_phased_plugin_input" | "project_phased_plugin_input" | "resolve_phased_plugin_input" | "mount_phased_plugins" | "activate_phased_plugins" | "phased_plugin_readiness" | "run_blueprint_steps" | "apply_distribution" | "import_fixture_databases" | "run_distribution_setup_artifacts" | "run_distribution_startup_probes" | "run_workloads" | "run_adversarial_campaigns" | "run_probes" | "collect_artifacts" export interface RecipePhaseEvidence { schema: "wp-codebox/recipe-phase-evidence/v1" diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index 22ae38ced..1555d7f32 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -26,9 +26,10 @@ import { RecipeArtifactsMountConflictError, recipeArtifactsMountConflict } from import { createRecipeInterruptionController, interruptedRecipeOutput, markRecipeArtifactsFinalized, recipeInterruptionSerializedError } from "./recipe-run-interruption.js" import { bestEffortTimeout, exitAfterPlaygroundCliBootFailure, exitAfterRecipeRunTimeout, exitAfterTerminalRecipePhaseFailure, printJsonFailureDiagnostic, RecipeRunTimeoutError, RecipeRuntimeCreateError, serializeRecipeRunError, writeRecipeJsonOutput, writeRecipeSummaryHumanOutput } from "./recipe-run-output.js" import { RecipePhaseError } from "./recipe-run-phases.js" +import { collectPhasedPluginArtifact, projectPhasedPluginPackages, RecipePhasedPluginInputError, resolvePhasedPluginPackages } from "./recipe-phased-plugin-input.js" import { markPreviewLeaseAvailable, markPreviewLeaseFailed, markPreviewLeaseReleased, startPreviewLeaseRecipeRun } from "./preview-lease.js" import { importRecipeSiteSeeds } from "./recipe-site-seeds.js" -import { applyRecipeRuntimeSetup, cleanupInputMountBaselines, prepareRecipeRuntimeSetup, recipeRunDependencyOverlay, recipeRunExtraPlugin, recipeRunStagedFile, rewriteInputMountPathArgs } from "./recipe-runtime-setup.js" +import { applyPhasedRecipePlugins, applyRecipeRuntimeSetup, cleanupInputMountBaselines, preparePhasedRecipePlugins, prepareRecipeRuntimeSetup, recipeRunDependencyOverlay, recipeRunExtraPlugin, recipeRunStagedFile, rewriteInputMountPathArgs } from "./recipe-runtime-setup.js" import { provisionRuntimeServices, provisionRuntimeServicesForRecipe, runtimeServiceEvidenceFromError, type RuntimeServiceEvidence } from "../runtime-services.js" import { executeSmtpSinkRecipeOperation, isSmtpSinkRecipeOperation } from "../smtp-sink-recipe-operations.js" import { distributionStartupProbeFailure, executeRecipeCollectWorkloadResult, executeRecipeWorkflowStep, recipeAdvisoryFailure, recipeBrowserEvidence, recipeStepFailure, recipeWorkflowArgsEvidence, recipeWorkflowStepIsAdvisory, runDistributionSetupArtifacts, runDistributionStartupProbes, runRecipeProbes, withRecipeExecutionPhase } from "./recipe-run-workflow-evidence.js" @@ -49,13 +50,15 @@ export async function runRecipeRunCommand(args: string[]): Promise { const interruption = options.dryRun ? undefined : createRecipeInterruptionController() interruption?.install() const execute = (): Promise => options.dryRun ? dryRunRecipe(options, { defaultWordPressVersion: DEFAULT_WORDPRESS_VERSION, resolveExecutionSpec: recipeExecutionSpec }) : runRecipe(options, interruption) + const outputHeartbeat = options.outputPath && options.json ? setInterval(() => process.stderr.write("WP Codebox recipe-run active\n"), 30_000) : undefined + outputHeartbeat?.unref() try { if (options.summary) { const { result } = await captureStdout(execute) const output = interruptedRecipeOutput(result, interruption) const summary = normalizeRecipeRunSummary(output) - if (options.json) await writeRecipeJsonOutput(summary) + if (options.json) await writeRecipeJsonOutput(summary, options.outputPath) else await writeRecipeSummaryHumanOutput(summary) interruption?.propagateIfInterrupted() exitAfterRecipeRunTimeout(output) @@ -77,7 +80,7 @@ export async function runRecipeRunCommand(args: string[]): Promise { const { result, logs } = await captureStdout(execute) const interruptedResult = interruptedRecipeOutput(result, interruption) const output = logs.length > 0 ? { ...interruptedResult, logs } : interruptedResult - await writeRecipeJsonOutput(output) + await writeRecipeJsonOutput(output, options.outputPath) printJsonFailureDiagnostic(output) interruption?.propagateIfInterrupted() exitAfterRecipeRunTimeout(output) @@ -85,6 +88,7 @@ export async function runRecipeRunCommand(args: string[]): Promise { exitAfterTerminalRecipePhaseFailure(output) return output.success ? 0 : 1 } finally { + if (outputHeartbeat) clearInterval(outputHeartbeat) interruption?.dispose() } } @@ -327,6 +331,27 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe ? (() => executeSmtpSinkRecipeOperation(workflowStep.step, managedServices!).then(({ execution, evidenceArgs }) => withRecipeExecutionPhase(execution, workflowStep.phase, workflowStep.index, workflowStep.step.command, recipeWorkflowArgsEvidence(evidenceArgs, evidenceArgs), workflowStep.step.metadata)))() : executeRecipeWorkflowStep(runtime!, workflowStep, recipeDirectory, sandboxWorkspace, configuredArtifactsDirectory, options, inputMountPathMap, (progress) => { continuationProgress = progress }), workflowStep.step.timeoutMs) executions.push({ ...execution, ...(recipeWorkflowStepIsAdvisory(workflowStep.step) ? { recipeAdvisory: true } : {}) }) + if (workflowStep.step.pluginInput) { + const pluginInput = workflowStep.step.pluginInput + const phasedArtifact = await phaseTracker.run("collect_phased_plugin_input", { artifact: pluginInput.artifact }, () => awaitRecipe(`${operation}.plugin-input.collect`, () => collectPhasedPluginArtifact(recipe, workflowStep.step, runtime!))) + const descriptors = await phaseTracker.run("project_phased_plugin_input", { artifact: pluginInput.artifact, resolver: pluginInput.packages.resolver ?? "immutable-archive" }, async () => projectPhasedPluginPackages(phasedArtifact.parsedJson, pluginInput.packages)) + const preparedPlugins = await phaseTracker.run("resolve_phased_plugin_input", { count: descriptors.length, resolver: pluginInput.packages.resolver ?? "immutable-archive" }, async () => { + const projectedPlugins = await resolvePhasedPluginPackages(descriptors, pluginInput.packages) + return await preparePhasedRecipePlugins(projectedPlugins, recipeDirectory, { allowWordPressOrgDownloads: pluginInput.packages.resolver === "wordpress.org-latest-stable" }) + }) + const existingSlugs = new Set(extraPlugins.map((plugin) => plugin.slug)) + const duplicate = preparedPlugins.find((plugin) => existingSlugs.has(plugin.slug)) + if (duplicate) throw new RecipePhasedPluginInputError(`Phased plugin input duplicates an already mounted plugin: ${duplicate.slug}.`, "resolve", { slug: duplicate.slug }) + extraPlugins.push(...preparedPlugins) + executions.push(...await awaitRecipe(`${operation}.plugin-input.apply`, () => applyPhasedRecipePlugins({ + plugins: preparedPlugins, + runtime: runtime!, + phaseExecutor, + interruption, + recipePhase: workflowStep.phase, + recipeStepIndex: workflowStep.index, + }))) + } interruption?.throwIfInterrupted() } catch (error) { const failure = recipeStepFailure(workflowStep, error, stepStartedAtMs, Date.now(), continuationProgress) @@ -809,6 +834,9 @@ function parseRecipeRunOptions(args: string[]): RecipeRunOptions { case "--recipe": options.recipePath = value break + case "--output": + options.outputPath = value + break case "--artifacts": options.artifactsDirectory = value break diff --git a/packages/cli/src/commands/recipe-runtime-setup.ts b/packages/cli/src/commands/recipe-runtime-setup.ts index c09132452..b49ca82d5 100644 --- a/packages/cli/src/commands/recipe-runtime-setup.ts +++ b/packages/cli/src/commands/recipe-runtime-setup.ts @@ -1,16 +1,16 @@ import { cp, mkdtemp, rm, stat } from "node:fs/promises" import { tmpdir } from "node:os" import { join, posix, resolve } from "node:path" -import { booleanCommandArg, phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type RuntimeCreateSpec, type WorkspaceRecipe, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core" +import { booleanCommandArg, phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type RuntimeCreateSpec, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core" import { requiresManagedMysqlMultisitePreinstall } from "@automattic/wp-codebox-playground" -import { installMuPluginsCode, installPluginComposerAutoloadersCode, prepareRecipeDependencyOverlays, prepareRecipeExtraPlugins, prepareRecipeRuntimeOverlays, prepareRecipeStagedFiles, prepareRecipeWorkspacePreloads, prepareRecipeWorkspaces, recipeMountType, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js" +import { installMuPluginsCode, installPluginComposerAutoloadersCode, prepareExtraPlugins, prepareRecipeDependencyOverlays, prepareRecipeExtraPlugins, prepareRecipeRuntimeOverlays, prepareRecipeStagedFiles, prepareRecipeWorkspacePreloads, prepareRecipeWorkspaces, recipeMountType, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js" import { pluginRuntimeHealthProbeStep, type RecipeWorkflowPhase } from "../recipe-validation.js" import { pluginRuntimeHealthProbeStepIndex, pluginRuntimeSetupStepIndex } from "../recipe-dry-run.js" import { prepareRecipeRuntimeBackendPackage, type PreparedRuntimeBackendPackage } from "../recipe-backend-package.js" import { recipeExecutionSpec } from "../agent-sandbox.js" import { recipeInputMountPathMap, type InputMountPathMapping } from "../input-mount-paths.js" import type { RecipeRunPhaseExecutor } from "./recipe-run-phase-executor.js" -import type { RecipeExecutionResult, RecipeInterruptionController, RecipePhaseEvidence } from "./recipe-run-types.js" +import type { RecipeExecutionResult, RecipeInterruptionController, RecipePhaseEvidence, RecipePhaseName } from "./recipe-run-types.js" export { assertResolvedInputMountPathArgs, recipeInputMountPathMap, rewriteInputMountPath, rewriteInputMountPathArgs, type InputMountPathMapping } from "../input-mount-paths.js" @@ -29,6 +29,50 @@ export interface RecipeRuntimeSetupResult { executions: RecipeExecutionResult[] } +export async function preparePhasedRecipePlugins(plugins: WorkspaceRecipeExtraPlugin[], recipeDirectory: string, options: { allowWordPressOrgDownloads?: boolean } = {}): Promise { + return prepareExtraPlugins(plugins, recipeDirectory, options) +} + +export async function applyPhasedRecipePlugins(args: { + plugins: PreparedExtraPlugin[] + runtime: Runtime + phaseExecutor: RecipeRunPhaseExecutor + interruption?: RecipeInterruptionController + recipePhase: RecipeWorkflowPhase + recipeStepIndex: number +}): Promise { + const { plugins, runtime, phaseExecutor, interruption, recipePhase, recipeStepIndex } = args + const executions: RecipeExecutionResult[] = [] + const mounts = preparedExtraPluginMounts(plugins) + await mountPreparedExtraPlugins(runtime, plugins, mounts, phaseExecutor, interruption, "mount_phased_plugins") + if (mounts.length > 0) { + await phaseExecutor.operation("phased-plugin.materialize", () => materializePreparedMounts(runtime, mounts)) + interruption?.throwIfInterrupted() + } + const activatedPlugins = plugins.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false) + executions.push(...await installPreparedExtraPluginRuntime({ + runtime, + plugins, + activatedPlugins, + phaseExecutor, + interruption, + recipePhase, + loaderStepIndex: recipeStepIndex, + activationStepIndex: recipeStepIndex, + activationPhase: "activate_phased_plugins", + })) + if (activatedPlugins.length > 0) { + await phaseExecutor.tracker.run("phased_plugin_readiness", { count: activatedPlugins.length, plugins: activatedPlugins.map((plugin) => ({ slug: plugin.slug, pluginFile: plugin.pluginFile })) }, async () => { + for (const plugin of activatedPlugins) { + const probe = pluginRuntimeHealthProbeStep({ name: `phased-plugin-active:${plugin.pluginFile}`, type: "plugin-active", pluginFile: plugin.pluginFile }) + executions.push(withRecipeExecutionPhase(await runtime.execute({ command: probe.command, args: probe.args ?? [] }), recipePhase, recipeStepIndex, `phased-plugin.readiness:${plugin.pluginFile}`)) + interruption?.throwIfInterrupted() + } + }) + } + return executions +} + export async function prepareRecipeRuntimeSetup(recipe: WorkspaceRecipe, recipeDirectory: string, runtimeBackend: string): Promise { const extraPlugins = await prepareRecipeExtraPlugins(recipe, recipeDirectory) const workspaceMounts = [ @@ -129,23 +173,8 @@ export async function applyRecipeRuntimeSetup(args: { interruption?.throwIfInterrupted() } - const extraPluginMounts: MountSpec[] = extraPlugins.map((plugin) => ({ - type: "directory", - source: plugin.source, - target: plugin.target, - mode: "readonly", - metadata: { - kind: "extra-plugin", - slug: plugin.slug, - source: plugin.provenance, - }, - })) - await phaseTracker.run("mount_plugins", phasePluginMountData(extraPlugins), async () => { - for (const [index, plugin] of extraPlugins.entries()) { - await awaitRecipe(`extra-plugin.mount:${plugin.slug}`, runtime.mount(extraPluginMounts[index])) - interruption?.throwIfInterrupted() - } - }) + const extraPluginMounts = preparedExtraPluginMounts(extraPlugins) + await mountPreparedExtraPlugins(runtime, extraPlugins, extraPluginMounts, phaseExecutor, interruption, "mount_plugins") for (const overlay of overlayCopies) { executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(copyRuntimeOverlayCode(overlay.source, overlay.target)) }), "setup", -3, `runtime.overlay.copy:${overlay.target}`)) @@ -204,9 +233,8 @@ export async function applyRecipeRuntimeSetup(args: { metadata: stagedFile.metadata, })), ] - const canMaterializeStagedInputs = typeof runtime.materializeStagedInputs === "function" || typeof runtime.materializeMounts === "function" - if (materializableMounts.length > 0 && canMaterializeStagedInputs) { - await awaitRecipe("input.materialize", () => runtime.materializeStagedInputs ? runtime.materializeStagedInputs(materializableMounts) : runtime.materializeMounts!(materializableMounts)) + if (materializableMounts.length > 0 && canMaterializeMounts(runtime)) { + await awaitRecipe("input.materialize", () => materializePreparedMounts(runtime, materializableMounts)) interruption?.throwIfInterrupted() } @@ -218,31 +246,19 @@ export async function applyRecipeRuntimeSetup(args: { } const isolateManagedMultisitePreinstall = recipeHasManagedMysqlMultisitePhpunit(recipe, runtimeSpec) - const muPluginInstallCode = isolateManagedMultisitePreinstall ? null : installMuPluginsCode(extraPlugins) - if (muPluginInstallCode) { - executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(muPluginInstallCode) }), "setup", -2, "extra-plugin.install-mu-loader")) - } - - const composerAutoloaderInstallCode = isolateManagedMultisitePreinstall ? null : installPluginComposerAutoloadersCode(extraPlugins) - if (composerAutoloaderInstallCode) { - executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(composerAutoloaderInstallCode) }), "setup", -2, "extra-plugin.install-composer-autoloaders")) - } - const deferredPluginFiles = managedPhpunitDeferredPluginFiles(recipe, runtimeSpec) const activatedPlugins = extraPlugins.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false && !deferredPluginFiles.has(plugin.pluginFile)) - if (activatedPlugins.length > 0) { - const activePluginsAfterActivation = await phaseTracker.run("activate_plugins", phasePluginActivationData(activatedPlugins), async () => { - for (const plugin of activatedPlugins) { - executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(activateExtraPluginCode(plugin)) }), "setup", -1, `extra-plugin.activate:${plugin.pluginFile}`)) - interruption?.throwIfInterrupted() - } - return await activePlugins(runtime) - }) - const activationPhase = [...phaseTracker.list()].reverse().find((phase: RecipePhaseEvidence) => phase.name === "activate_plugins") - if (activationPhase?.data) { - activationPhase.data.activePlugins = activePluginsAfterActivation - } - } + executions.push(...await installPreparedExtraPluginRuntime({ + runtime, + plugins: isolateManagedMultisitePreinstall ? [] : extraPlugins, + activatedPlugins, + phaseExecutor, + interruption, + recipePhase: "setup", + loaderStepIndex: -2, + activationStepIndex: -1, + activationPhase: "activate_plugins", + })) for (const [index, setupStep] of (recipe.inputs?.pluginRuntime?.setup ?? []).entries()) { executions.push(await awaitRecipe(`plugin-runtime.setup[${index}]`, executeRecipePluginRuntimeStep(runtime, setupStep, recipeDirectory, "setup", index, inputMountPathMap))) @@ -431,6 +447,74 @@ foreach ($iterator as $item) { echo wp_json_encode(array('source' => $source, 'target' => $target, 'copied' => true));`; } +function preparedExtraPluginMounts(plugins: PreparedExtraPlugin[]): MountSpec[] { + return plugins.map((plugin) => ({ + type: "directory", + source: plugin.source, + target: plugin.target, + mode: "readonly", + metadata: { + kind: "extra-plugin", + slug: plugin.slug, + source: plugin.provenance, + }, + })) +} + +async function mountPreparedExtraPlugins(runtime: Runtime, plugins: PreparedExtraPlugin[], mounts: MountSpec[], phaseExecutor: RecipeRunPhaseExecutor, interruption: RecipeInterruptionController | undefined, phaseName: RecipePhaseName): Promise { + await phaseExecutor.tracker.run(phaseName, phasePluginMountData(plugins), async () => { + for (const [index, plugin] of plugins.entries()) { + await phaseExecutor.operation(`extra-plugin.mount:${plugin.slug}`, runtime.mount(mounts[index])) + interruption?.throwIfInterrupted() + } + }) +} + +function canMaterializeMounts(runtime: Runtime): boolean { + return typeof runtime.materializeStagedInputs === "function" || typeof runtime.materializeMounts === "function" +} + +async function materializePreparedMounts(runtime: Runtime, mounts: MountSpec[]): Promise { + if (runtime.materializeStagedInputs) return runtime.materializeStagedInputs(mounts) + if (runtime.materializeMounts) return runtime.materializeMounts(mounts) + return undefined +} + +async function installPreparedExtraPluginRuntime(args: { + runtime: Runtime + plugins: PreparedExtraPlugin[] + activatedPlugins: PreparedExtraPlugin[] + phaseExecutor: RecipeRunPhaseExecutor + interruption?: RecipeInterruptionController + recipePhase: RecipeWorkflowPhase + loaderStepIndex: number + activationStepIndex: number + activationPhase: RecipePhaseName +}): Promise { + const { runtime, plugins, activatedPlugins, phaseExecutor, interruption, recipePhase, loaderStepIndex, activationStepIndex, activationPhase } = args + const executions: RecipeExecutionResult[] = [] + const muPluginInstallCode = installMuPluginsCode(plugins) + if (muPluginInstallCode) { + executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(muPluginInstallCode) }), recipePhase, loaderStepIndex, "extra-plugin.install-mu-loader")) + } + const composerAutoloaderInstallCode = installPluginComposerAutoloadersCode(plugins) + if (composerAutoloaderInstallCode) { + executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(composerAutoloaderInstallCode) }), recipePhase, loaderStepIndex, "extra-plugin.install-composer-autoloaders")) + } + if (activatedPlugins.length === 0) return executions + + const activePluginsAfterActivation = await phaseExecutor.tracker.run(activationPhase, phasePluginActivationData(activatedPlugins), async () => { + for (const plugin of activatedPlugins) { + executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(activateExtraPluginCode(plugin)) }), recipePhase, activationStepIndex, `extra-plugin.activate:${plugin.pluginFile}`)) + interruption?.throwIfInterrupted() + } + return activePlugins(runtime) + }) + const activationEvidence = [...phaseExecutor.tracker.list()].reverse().find((phase: RecipePhaseEvidence) => phase.name === activationPhase) + if (activationEvidence?.data) activationEvidence.data.activePlugins = activePluginsAfterActivation + return executions +} + function phasePluginMountData(extraPlugins: PreparedExtraPlugin[]): Record { return { count: extraPlugins.length, diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index 98f99677e..2646845cf 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -343,7 +343,7 @@ export function printHelp(): void { Options: --recipe Workspace recipe JSON file for recipe-run or recipe validate. --options Recipe builder options JSON file for recipe build. - --output Recipe build output JSON path, or materialize-replay-package output directory. + --output Recipe build/run output JSON path, or materialize-replay-package output directory. --input-file Input JSON for public workload/fuzz commands or agent-task-run. --result-file Atomically write the final agent-task-run JSON result to a caller-owned file. --format=json Emit machine-readable JSON; accepted by public workload/fuzz commands. diff --git a/packages/cli/src/recipe-sources.ts b/packages/cli/src/recipe-sources.ts index c70276342..e5244dc87 100644 --- a/packages/cli/src/recipe-sources.ts +++ b/packages/cli/src/recipe-sources.ts @@ -276,35 +276,43 @@ export async function cleanupRecipePreparedSources(workspaces: PreparedWorkspace } export async function prepareRecipeExtraPlugins(recipe: WorkspaceRecipe, recipeDirectory: string): Promise { - const plugins: PreparedExtraPlugin[] = [] - for (const plugin of recipeExtraPlugins(recipe)) { - const slug = recipeExtraPluginSlug(plugin) - const sourceRef = recipeExtraPluginSource(plugin) - const sourceRootRef = recipeExtraPluginSourceRoot(plugin, recipeDirectory) - const sourceSubpath = recipeExtraPluginSourceSubpath(plugin, recipeDirectory) - const resolved = await prepareRecipeSource(sourceRootRef, recipeDirectory, slug, plugin.sha256) - const pluginResolved = sourceSubpath ? { ...resolved, source: join(resolved.source, sourceSubpath) } : resolved - const pluginFile = await resolveRecipeExtraPluginFile(plugin, recipeDirectory) - const loadAs = plugin.loadAs ?? "plugin" - const prepared = await prepareComposerAutoloadForPlugin(pluginResolved, slug, sourceRef, plugin.composer, resolved.source) - await assertPreparedPluginFileExists(prepared.source, pluginFile.slice(slug.length + 1), sourceRef) - plugins.push({ - source: prepared.source, - slug, - target: pluginTarget(slug, loadAs), - pluginFile, - activate: plugin.activate !== false, - loadAs, - cleanupPaths: prepared.cleanupPaths, - provenance: prepared.provenance, - metadata: { - ...(plugin.metadata ?? {}), - ...(sourceSubpath ? { sourceRoot: sourceRootRef, sourceSubpath } : {}), - }, - }) - } + return prepareExtraPlugins(recipeExtraPlugins(recipe), recipeDirectory) +} - return plugins +export async function prepareExtraPlugins(plugins: readonly WorkspaceRecipeExtraPlugin[], recipeDirectory: string, options: { allowWordPressOrgDownloads?: boolean } = {}): Promise { + const preparedPlugins: PreparedExtraPlugin[] = [] + try { + for (const plugin of plugins) { + const slug = recipeExtraPluginSlug(plugin) + const sourceRef = recipeExtraPluginSource(plugin) + const sourceRootRef = recipeExtraPluginSourceRoot(plugin, recipeDirectory) + const sourceSubpath = recipeExtraPluginSourceSubpath(plugin, recipeDirectory) + const resolved = await prepareRecipeSource(sourceRootRef, recipeDirectory, slug, plugin.sha256, options) + const pluginResolved = sourceSubpath ? { ...resolved, source: join(resolved.source, sourceSubpath) } : resolved + const pluginFile = await resolveRecipeExtraPluginFile(plugin, recipeDirectory) + const loadAs = plugin.loadAs ?? "plugin" + const prepared = await prepareComposerAutoloadForPlugin(pluginResolved, slug, sourceRef, plugin.composer, resolved.source) + await assertPreparedPluginFileExists(prepared.source, pluginFile.slice(slug.length + 1), sourceRef) + preparedPlugins.push({ + source: prepared.source, + slug, + target: pluginTarget(slug, loadAs), + pluginFile, + activate: plugin.activate !== false, + loadAs, + cleanupPaths: prepared.cleanupPaths, + provenance: prepared.provenance, + metadata: { + ...(plugin.metadata ?? {}), + ...(sourceSubpath ? { sourceRoot: sourceRootRef, sourceSubpath } : {}), + }, + }) + } + return preparedPlugins + } catch (error) { + await Promise.all(preparedPlugins.flatMap((plugin) => plugin.cleanupPaths).map((path) => rm(path, { recursive: true, force: true }))) + throw error + } } async function prepareComposerAutoloadForPlugin(prepared: PreparedExternalSource, slug: string, sourceRef: string, strategy: WorkspaceRecipeExtraPlugin["composer"], copyRoot = prepared.source): Promise { @@ -1621,7 +1629,7 @@ async function assertPreparedPluginFileExists(sourceDirectory: string, pluginFil throw new Error(`Recipe extra plugin source did not contain expected plugin file ${pluginFileRelativeToSource}: ${sourceRef}`) } -async function prepareRecipeSource(sourceRef: string, recipeDirectory: string, slug: string, expectedSha256?: string): Promise { +async function prepareRecipeSource(sourceRef: string, recipeDirectory: string, slug: string, expectedSha256?: string, options: { allowWordPressOrgDownloads?: boolean } = {}): Promise { const source = recipeSource(sourceRef, expectedSha256) if (source.type === "local") { const localPath = resolve(recipeDirectory, sourceRef) @@ -1647,7 +1655,7 @@ async function prepareRecipeSource(sourceRef: string, recipeDirectory: string, s } } - const [policyIssue] = evaluateRecipeSourcePolicy(source, expectedSha256) + const [policyIssue] = evaluateSourcePolicy(source, expectedSha256, { networkDownloadsAllowed: options.allowWordPressOrgDownloads === true && source.type === "wporg_plugin_zip" }) if (policyIssue) { throw new Error(policyIssue.message) } diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index 8cffd8b64..5a842192d 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -603,6 +603,24 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe, } await validateRecipeStepArgs(step, path, addIssue, recipeDirectory) + if (step.pluginInput) { + const artifact = (recipe.artifacts?.typed ?? []).find((candidate) => candidate.name === step.pluginInput?.artifact) + if (!artifact) { + addIssue("unknown-phased-plugin-artifact", `${path}.pluginInput.artifact`, `Phased plugin input must reference a declared typed artifact: ${step.pluginInput.artifact}`) + } else { + if (artifact.required === false || artifact.parseJson !== true || artifact.contentType !== "application/json") { + addIssue("invalid-phased-plugin-artifact", `${path}.pluginInput.artifact`, "Phased plugin input artifacts must be required JSON artifacts with parseJson=true and contentType=application/json.") + } + if (artifact.payloadSchema === undefined) addIssue("missing-phased-plugin-schema", `${path}.pluginInput.artifact`, "Phased plugin input artifacts must declare their consumer-owned payload schema.") + } + const resolver = step.pluginInput.packages.resolver ?? "immutable-archive" + const map = step.pluginInput.packages.map + if (resolver === "immutable-archive" && (!map.source || !map.sha256)) addIssue("incomplete-phased-plugin-projection", `${path}.pluginInput.packages.map`, "Immutable archive projections must map source and sha256.") + if (resolver === "wordpress.org-latest-stable" && (map.source || map.sha256)) addIssue("ambiguous-phased-plugin-projection", `${path}.pluginInput.packages.map`, "WordPress.org projections are resolved from slug on the host and must not map guest-supplied source or sha256 fields.") + if (step.allowFailure === true || step.advisory === true) { + addIssue("optional-phased-plugin-step", `${path}.pluginInput`, "A phased plugin input step must be required so later workflow steps cannot run without its packages.") + } + } } for (const [index, mount] of (recipe.inputs?.mounts ?? []).entries()) { diff --git a/packages/cli/src/source-policy.ts b/packages/cli/src/source-policy.ts index 2b5bd6c26..57d1b6cb8 100644 --- a/packages/cli/src/source-policy.ts +++ b/packages/cli/src/source-policy.ts @@ -32,13 +32,13 @@ export function isSha256(value: string): boolean { return /^[a-f0-9]{64}$/i.test(value) } -export function evaluateSourcePolicy(source: ExternalSourcePolicyInput, expectedSha256?: string): SourcePolicyIssue[] { +export function evaluateSourcePolicy(source: ExternalSourcePolicyInput, expectedSha256?: string, options: { networkDownloadsAllowed?: boolean } = {}): SourcePolicyIssue[] { if (source.type === "local") { return [] } const issues: SourcePolicyIssue[] = [] - if (process.env[ALLOW_NETWORK_DOWNLOADS_ENV] !== "1") { + if (process.env[ALLOW_NETWORK_DOWNLOADS_ENV] !== "1" && options.networkDownloadsAllowed !== true) { issues.push({ code: "network-downloads-disabled", message: `External recipe sources require ${ALLOW_NETWORK_DOWNLOADS_ENV}=1 before WP Codebox downloads anything.`, diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index d4484b4ef..440a332ab 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -1257,12 +1257,46 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche }, timeoutMs: { type: "integer", minimum: 1 }, continuation: { $ref: "#/$defs/stepContinuation" }, + pluginInput: { $ref: "#/$defs/stepPluginInput" }, diagnostics: { $ref: "#/$defs/commandDiagnosticsCapture" }, metadata: { $ref: "#/$defs/metadata" }, allowFailure: { type: "boolean" }, advisory: { type: "boolean" }, }, }, + stepPluginInput: { + type: "object", + additionalProperties: false, + required: ["artifact", "packages"], + description: "After this step succeeds, collect one declared typed JSON artifact, project it into immutable plugin package descriptors, resolve those packages on the host, and mount/activate them in the same runtime before later steps continue.", + properties: { + artifact: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_.-]*$" }, + packages: { $ref: "#/$defs/pluginPackageProjection" }, + }, + }, + pluginPackageProjection: { + type: "object", + additionalProperties: false, + required: ["items", "map"], + description: "Deterministic JSON Pointer projection from a consumer-owned typed artifact into generic immutable plugin package descriptors. Ambient stdout and key-name matching cannot supply package inputs.", + properties: { + resolver: { enum: ["immutable-archive", "wordpress.org-latest-stable"] }, + items: { $ref: "#/$defs/jsonPointer" }, + map: { + type: "object", + additionalProperties: false, + required: ["slug"], + properties: { + source: { $ref: "#/$defs/jsonPointer" }, + sha256: { $ref: "#/$defs/jsonPointer" }, + slug: { $ref: "#/$defs/jsonPointer" }, + pluginFile: { $ref: "#/$defs/jsonPointer" }, + activate: { $ref: "#/$defs/jsonPointer" }, + loadAs: { $ref: "#/$defs/jsonPointer" }, + }, + }, + }, + }, stepContinuation: { type: "object", additionalProperties: false, diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index 25760648f..3db0546ac 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -296,12 +296,31 @@ export interface WorkspaceRecipeStep { args?: string[] timeoutMs?: number continuation?: WorkspaceRecipeStepContinuation + pluginInput?: WorkspaceRecipeStepPluginInput diagnostics?: RuntimeCommandDiagnosticsCaptureSpec metadata?: Record allowFailure?: boolean advisory?: boolean } +export interface WorkspaceRecipeStepPluginInput { + artifact: string + packages: WorkspaceRecipePluginPackageProjection +} + +export interface WorkspaceRecipePluginPackageProjection { + resolver?: "immutable-archive" | "wordpress.org-latest-stable" + items: string + map: { + source?: string + sha256?: string + slug: string + pluginFile?: string + activate?: string + loadAs?: string + } +} + export interface WorkspaceRecipeStepContinuation { maxIterations: number while: WorkspaceRecipeStepContinuationPredicate @@ -524,6 +543,16 @@ export interface WorkspaceRecipeExtraPlugin { metadata?: Record } +export interface ProjectedPluginPackageDescriptor { + source?: string + sha256?: string + slug: string + pluginFile?: string + activate?: boolean + loadAs?: "plugin" | "mu-plugin" + metadata?: Record +} + export interface WorkspaceRecipeComponentManifestEntry { slug?: string source?: string diff --git a/tests/recipe-phased-plugin-input.test.ts b/tests/recipe-phased-plugin-input.test.ts new file mode 100644 index 000000000..84121d2bc --- /dev/null +++ b/tests/recipe-phased-plugin-input.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict" + +import { validateWorkspaceRecipeJsonSchema, type ExecutionResult, type MountSpec, type Runtime, type WorkspaceRecipe } from "../packages/runtime-core/src/index.js" +import { collectPhasedPluginArtifact, projectPhasedPluginPackages, RecipePhasedPluginInputError, resolvePhasedPluginPackages } from "../packages/cli/src/commands/recipe-phased-plugin-input.js" +import { RecipeRunPhaseExecutor } from "../packages/cli/src/commands/recipe-run-phase-executor.js" +import { applyPhasedRecipePlugins } from "../packages/cli/src/commands/recipe-runtime-setup.js" +import type { PreparedExtraPlugin } from "../packages/cli/src/recipe-sources.js" +import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.js" +import { evaluateSourcePolicy } from "../packages/cli/src/source-policy.js" + +const payload = { + schema: "example/provider-plan/v1", + dependencies: [{ package: { url: "https://downloads.wordpress.org/plugin/example.1.2.3.zip", digest: "a".repeat(64) }, slug: "example", entrypoint: "example/example.php", required: true }], +} +const immutableProjection = { + resolver: "immutable-archive" as const, + items: "/dependencies", + map: { source: "/package/url", sha256: "/package/digest", slug: "/slug", pluginFile: "/entrypoint", activate: "/required" }, +} + +assert.deepEqual(projectPhasedPluginPackages(payload, immutableProjection), [{ + source: "https://downloads.wordpress.org/plugin/example.1.2.3.zip", + sha256: "a".repeat(64), + slug: "example", + pluginFile: "example/example.php", + activate: true, + loadAs: "plugin", +}]) +assert.deepEqual(projectPhasedPluginPackages({ dependencies: [] }, immutableProjection), []) +assert.throws(() => projectPhasedPluginPackages({ dependencies: [{ ...payload.dependencies[0], package: { ...payload.dependencies[0].package, url: "/tmp/host-plugin" } }] }, immutableProjection), RecipePhasedPluginInputError) +assert.throws(() => projectPhasedPluginPackages({ dependencies: [payload.dependencies[0], payload.dependencies[0]] }, immutableProjection), /duplicates/) +assert.throws(() => projectPhasedPluginPackages({ dependencies: Array.from({ length: 21 }, (_, index) => ({ ...payload.dependencies[0], slug: `example-${index}`, entrypoint: `example-${index}/example.php` })) }, immutableProjection), /20-plugin bound/) + +const registryProjection = { resolver: "wordpress.org-latest-stable" as const, items: "/entries", map: { slug: "/slug", pluginFile: "/plugin_file" } } +const registryDescriptors = projectPhasedPluginPackages({ entries: [{ slug: "woocommerce", plugin_file: "woocommerce/woocommerce.php" }] }, registryProjection) +const registryPlugins = await resolvePhasedPluginPackages(registryDescriptors, registryProjection, async () => new Response(JSON.stringify({ version: "10.1.2", download_link: "https://downloads.wordpress.org/plugin/woocommerce.10.1.2.zip" }), { headers: { "content-type": "application/json" } })) +assert.equal(registryPlugins[0]?.source, "https://downloads.wordpress.org/plugin/woocommerce.10.1.2.zip") +assert.deepEqual(registryPlugins[0]?.metadata, { phased_input: { resolver: "wordpress.org-latest-stable", version: "10.1.2", source_url: "https://downloads.wordpress.org/plugin/woocommerce.10.1.2.zip" } }) +await assert.rejects(() => resolvePhasedPluginPackages(registryDescriptors, registryProjection, async () => new Response(JSON.stringify({ version: "10.1.2", download_link: "https://evil.example/plugin.zip" }), { headers: { "content-type": "application/json" } })), RecipePhasedPluginInputError) +assert.deepEqual(evaluateSourcePolicy({ type: "wporg_plugin_zip", host: "downloads.wordpress.org" }, undefined, { networkDownloadsAllowed: true }), []) + +const recipe: WorkspaceRecipe = { + schema: "wp-codebox/workspace-recipe/v1", + workflow: { + steps: [{ command: "wordpress.wp-cli", args: ["command=example discover"], pluginInput: { artifact: "provider-plugins", packages: immutableProjection } }], + }, + artifacts: { + typed: [{ name: "provider-plugins", type: "example/provider-plugins", path: "/wordpress/wp-content/uploads/provider-plugins.json", required: true, parseJson: true, contentType: "application/json", payloadSchema: "example/provider-plan/v1" }], + }, +} + +assert.equal(validateWorkspaceRecipeJsonSchema(recipe).valid, true) +assert.deepEqual(await validateWorkspaceRecipeSemantics(recipe, "/tmp/recipe.json"), []) +assert.ok((await validateWorkspaceRecipeSemantics({ ...recipe, artifacts: { typed: [] } }, "/tmp/recipe.json")).some((issue) => issue.code === "unknown-phased-plugin-artifact")) +assert.ok((await validateWorkspaceRecipeSemantics({ ...recipe, workflow: { steps: [{ ...recipe.workflow.steps[0], advisory: true }] } }, "/tmp/recipe.json")).some((issue) => issue.code === "optional-phased-plugin-step")) +assert.ok((await validateWorkspaceRecipeSemantics({ ...recipe, workflow: { steps: [{ ...recipe.workflow.steps[0], pluginInput: { artifact: "provider-plugins", packages: { ...immutableProjection, resolver: "wordpress.org-latest-stable" } } }] } }, "/tmp/recipe.json")).some((issue) => issue.code === "ambiguous-phased-plugin-projection")) + +class ArtifactRuntime { + async execute(): Promise { + const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64") + return { command: "wordpress.run-php", args: [], exitCode: 0, stdout: JSON.stringify({ exists: true, type: "file", size: encoded.length, sha256: "b".repeat(64), parsedJson: payload, contentBase64: encoded }), stderr: "" } + } +} + +const collected = await collectPhasedPluginArtifact(recipe, recipe.workflow.steps[0], new ArtifactRuntime() as unknown as Runtime) +assert.equal(collected.status, "collected") +assert.deepEqual(collected.parsedJson, payload) + +class PluginRuntime { + readonly mounts: MountSpec[] = [] + readonly materialized: MountSpec[][] = [] + async mount(spec: MountSpec): Promise { this.mounts.push(spec) } + async materializeStagedInputs(mounts: MountSpec[]): Promise { this.materialized.push(mounts) } + async execute(spec: { command: string; args?: string[] }): Promise { + const code = (spec.args ?? []).find((arg) => arg.startsWith("code=")) ?? "" + return { command: spec.command, args: spec.args ?? [], exitCode: 0, stdout: code.includes("get_option('active_plugins'") ? JSON.stringify(["example/example.php"]) : "{}", stderr: "" } + } +} +const runtime = new PluginRuntime() +const phaseExecutor = new RecipeRunPhaseExecutor({ context: { startedAtMs: Date.now(), artifactPointer: { update: async () => undefined } } as never, timeoutMs: 10_000, destroyActiveRuntime: async () => undefined }) +const preparedPlugin: PreparedExtraPlugin = { source: "/tmp/example", slug: "example", target: "/wordpress/wp-content/plugins/example", pluginFile: "example/example.php", activate: true, loadAs: "plugin", cleanupPaths: [], provenance: { kind: "local", original: "/tmp/example" }, metadata: {} } +const setupExecutions = await applyPhasedRecipePlugins({ plugins: [preparedPlugin], runtime: runtime as unknown as Runtime, phaseExecutor, recipePhase: "steps", recipeStepIndex: 0 }) +assert.equal(runtime.mounts[0]?.target, "/wordpress/wp-content/plugins/example") +assert.equal(runtime.materialized.length, 1) +assert.deepEqual(phaseExecutor.tracker.list().map((phase) => phase.name), ["mount_phased_plugins", "activate_phased_plugins", "phased_plugin_readiness"]) +assert.ok(setupExecutions.some((execution) => execution.recipeCommand === "extra-plugin.activate:example/example.php")) +assert.ok(setupExecutions.some((execution) => execution.recipeCommand === "phased-plugin.readiness:example/example.php")) + +console.log("recipe phased plugin input contract ok") diff --git a/tests/recipe-run-artifacts-mount-guard.test.ts b/tests/recipe-run-artifacts-mount-guard.test.ts index d892b1c32..e0028eaf2 100644 --- a/tests/recipe-run-artifacts-mount-guard.test.ts +++ b/tests/recipe-run-artifacts-mount-guard.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict" -import { mkdir, stat, writeFile } from "node:fs/promises" +import { mkdir, readFile, stat, writeFile } from "node:fs/promises" import { join, resolve } from "node:path" import { captureStdout } from "../packages/cli/src/output.js" @@ -52,6 +52,12 @@ await withTempDir("wp-codebox-recipe-artifacts-mount-guard-", async (recipeDirec assert.equal(output.error.conflict.artifactsDirectory, resolve(conflictingArtifacts)) assert.equal(output.error.conflict.mountSource, resolve(mountedSource)) assert.equal(await pathExists(conflictingArtifacts), false) + + const outputPath = join(recipeDirectory, "results", "recipe-run.json") + const fileRun = await captureStdout(async () => await runRecipeRunCommand(["--recipe", recipePath, "--artifacts", conflictingArtifacts, "--output", outputPath, "--json"])) + assert.equal(fileRun.result, 1) + assert.deepEqual(fileRun.logs, []) + assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), output) }) console.log("recipe run artifacts mount guard ok") diff --git a/tests/recipe-run-summary-output.test.ts b/tests/recipe-run-summary-output.test.ts index 3d17d0c4e..ff269ee65 100644 --- a/tests/recipe-run-summary-output.test.ts +++ b/tests/recipe-run-summary-output.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" import { normalizeRecipeRunSummary } from "@automattic/wp-codebox-core" import { writeRecipeJsonOutput, writeRecipeSummaryHumanOutput } from "../packages/cli/src/commands/recipe-run-output.js" @@ -70,6 +73,11 @@ assert.equal(parsed.status, "failed") assert.equal(parsed.failed_phase, "run_workloads") assert.equal(parsed.commands[0].exit_code, 1) +const outputPath = join(tmpdir(), `wp-codebox-recipe-run-output-${process.pid}`, "summary.json") +const fileStdout = await captureStdout(() => writeRecipeJsonOutput(failure, outputPath)) +assert.equal(fileStdout, "") +assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), parsed) + async function captureStdout(callback: () => Promise): Promise { const originalWrite = process.stdout.write.bind(process.stdout) let output = ""