Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions bin/wp-codebox-source.mjs
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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 = {}) {
Expand Down Expand Up @@ -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 })
57 changes: 57 additions & 0 deletions docs/recipe-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/recipe-declared-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ async function collectRecipeDeclaredArtifact(runtime: Runtime, artifact: Workspa
}
}

async function collectRecipeTypedArtifact(runtime: Runtime, artifact: WorkspaceRecipeTypedArtifact, index: number, effectivePath: string): Promise<RecipeRunDeclaredArtifact> {
export async function collectRecipeTypedArtifact(runtime: Runtime, artifact: WorkspaceRecipeTypedArtifact, index: number, effectivePath = artifact.path, options: { redact?: boolean } = {}): Promise<RecipeRunDeclaredArtifact> {
try {
const execution = await runtime.execute({
command: "wordpress.run-php",
Expand All @@ -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,
Expand Down
124 changes: 124 additions & 0 deletions packages/cli/src/commands/recipe-phased-plugin-input.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}, cause?: unknown) {
super(message, cause === undefined ? undefined : { cause })
this.name = "RecipePhasedPluginInputError"
}
}

export async function collectPhasedPluginArtifact(recipe: WorkspaceRecipe, step: WorkspaceRecipeStep, runtime: Runtime): Promise<RecipeRunDeclaredArtifact> {
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<string>()
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<WorkspaceRecipeExtraPlugin[]> {
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<string, unknown>)[segment]
else return { found: false }
}
return { found: true, value: current }
}
13 changes: 11 additions & 2 deletions packages/cli/src/commands/recipe-run-output.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<void> {
await writeStdout(`${JSON.stringify(boundedRecipeJsonOutput(output), null, 2)}\n`)
export async function writeRecipeJsonOutput(output: unknown, outputPath?: string): Promise<void> {
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 {
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/recipe-run-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { RecipeSourceProvenance } from "../recipe-sources.js"

export interface RecipeRunOptions {
recipePath: string
outputPath?: string
artifactsDirectory?: string
runRegistryDirectory?: string
previewHoldSeconds?: number
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading