Skip to content

Commit 6b69fc6

Browse files
authored
Merge pull request #2416 from Automattic/feat/2414-phased-plugin-inputs
Support host-resolved phased plugin inputs
2 parents 706105a + 95f68f3 commit 6b69fc6

17 files changed

Lines changed: 590 additions & 90 deletions

‎bin/wp-codebox-source.mjs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22
import { spawnSync } from "node:child_process"
3-
import { existsSync } from "node:fs"
3+
import { existsSync, readFileSync } from "node:fs"
44
import { dirname, join } from "node:path"
55
import { fileURLToPath } from "node:url"
66

@@ -9,6 +9,7 @@ const repoRoot = dirname(scriptDirectory)
99
const distEntrypoint = join(repoRoot, "packages/cli/dist/index.js")
1010
const nodeModules = join(repoRoot, "node_modules")
1111
const packageLock = join(repoRoot, "package-lock.json")
12+
const packageJson = join(repoRoot, "package.json")
1213
const callerCwd = process.cwd()
1314

1415
function run(command, args, options = {}) {
@@ -51,5 +52,7 @@ if (!existsSync(nodeModules)) {
5152
run("npm", [existsSync(packageLock) ? "ci" : "install"], { failureContext: "install source checkout dependencies" })
5253
}
5354

54-
run("npm", ["run", "build"], { failureContext: "build the source checkout" })
55+
const scripts = JSON.parse(readFileSync(packageJson, "utf8")).scripts ?? {}
56+
const buildScript = distIsAbsent && scripts["build:release"] ? "build:release" : "build"
57+
run("npm", ["run", buildScript], { failureContext: "build the source checkout" })
5558
run(process.execPath, [distEntrypoint, ...process.argv.slice(2)], { cwd: callerCwd, delegate: true })

‎docs/recipe-contract.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,63 @@ Use `allowFailure: true` or `advisory: true` for evidence-only workflow steps.
361361
Failed advisory steps are reported in `advisoryFailures` and do not make an
362362
otherwise successful recipe return `success: false`.
363363

364+
### Phased Plugin Inputs
365+
366+
A required workflow step may declare `pluginInput` to consume one named artifact
367+
from `artifacts.typed` before later steps continue. WP Codebox collects the JSON
368+
inside the live runtime, applies the declared JSON Pointer projection, resolves
369+
packages on the host, mounts and activates them in the same runtime, verifies
370+
plugin readiness, and then resumes the workflow.
371+
372+
```json
373+
{
374+
"workflow": {
375+
"steps": [
376+
{
377+
"command": "wordpress.wp-cli",
378+
"args": ["command=example plan --output=/tmp/provider-plan.json"],
379+
"pluginInput": {
380+
"artifact": "provider-plan",
381+
"packages": {
382+
"resolver": "wordpress.org-latest-stable",
383+
"items": "/entries",
384+
"map": {
385+
"slug": "/slug",
386+
"pluginFile": "/plugin_entrypoint"
387+
}
388+
}
389+
}
390+
},
391+
{ "command": "wordpress.wp-cli", "args": ["command=example import"] }
392+
]
393+
},
394+
"artifacts": {
395+
"typed": [
396+
{
397+
"name": "provider-plan",
398+
"type": "example/provider-plan",
399+
"path": "/tmp/provider-plan.json",
400+
"required": true,
401+
"parseJson": true,
402+
"contentType": "application/json",
403+
"payloadSchema": "example/provider-plan/v1"
404+
}
405+
]
406+
}
407+
}
408+
```
409+
410+
`immutable-archive` is the default resolver and requires `source`, `sha256`, and
411+
`slug` pointer mappings. `wordpress.org-latest-stable` accepts `slug` and an
412+
optional `pluginFile`, resolves the exact current version through the
413+
WordPress.org API on the host, and accepts packages only from
414+
`downloads.wordpress.org`. This explicit resolver authorizes that bounded host
415+
download without enabling network access inside WordPress. Typed artifact
416+
identity, projected package count, resolver, resolved source/version/digest,
417+
mount, activation, readiness, and failures remain visible in recipe evidence.
418+
Local paths, Composer execution, MU-plugin loading, ambient stdout, and inferred
419+
key-name mappings are not accepted from phased artifacts.
420+
364421
## Fuzz Case Runs
365422

366423
Recipes may declare a bounded, deterministic fuzz case skeleton with

‎packages/cli/src/commands/recipe-declared-artifacts.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ async function collectRecipeDeclaredArtifact(runtime: Runtime, artifact: Workspa
5959
}
6060
}
6161

62-
async function collectRecipeTypedArtifact(runtime: Runtime, artifact: WorkspaceRecipeTypedArtifact, index: number, effectivePath: string): Promise<RecipeRunDeclaredArtifact> {
62+
export async function collectRecipeTypedArtifact(runtime: Runtime, artifact: WorkspaceRecipeTypedArtifact, index: number, effectivePath = artifact.path, options: { redact?: boolean } = {}): Promise<RecipeRunDeclaredArtifact> {
6363
try {
6464
const execution = await runtime.execute({
6565
command: "wordpress.run-php",
@@ -78,7 +78,7 @@ async function collectRecipeTypedArtifact(runtime: Runtime, artifact: WorkspaceR
7878
type: collected.type,
7979
size: collected.size,
8080
sha256: collected.sha256,
81-
parsedJson: collected.parsedJson === undefined ? undefined : redactJsonValue(collected.parsedJson),
81+
parsedJson: collected.parsedJson === undefined ? undefined : options.redact === false ? collected.parsedJson : redactJsonValue(collected.parsedJson),
8282
typedArtifact: {
8383
name: artifact.name,
8484
type: artifact.type,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Buffer } from "node:buffer"
2+
import type { ProjectedPluginPackageDescriptor, Runtime, WorkspaceRecipe, WorkspaceRecipeExtraPlugin, WorkspaceRecipePluginPackageProjection, WorkspaceRecipeStep } from "@automattic/wp-codebox-core"
3+
import { recipeSource } from "../recipe-sources.js"
4+
import { collectRecipeTypedArtifact } from "./recipe-declared-artifacts.js"
5+
import type { RecipePhasedPluginInputStage, RecipeRunDeclaredArtifact } from "./recipe-run-types.js"
6+
7+
const MAX_PHASED_PLUGINS = 20
8+
const MAX_REGISTRY_RESPONSE_BYTES = 1024 * 1024
9+
10+
export class RecipePhasedPluginInputError extends Error {
11+
readonly code = "recipe-phased-plugin-input-failed"
12+
13+
constructor(message: string, readonly stage: RecipePhasedPluginInputStage, readonly context: Record<string, unknown> = {}, cause?: unknown) {
14+
super(message, cause === undefined ? undefined : { cause })
15+
this.name = "RecipePhasedPluginInputError"
16+
}
17+
}
18+
19+
export async function collectPhasedPluginArtifact(recipe: WorkspaceRecipe, step: WorkspaceRecipeStep, runtime: Runtime): Promise<RecipeRunDeclaredArtifact> {
20+
const artifactName = step.pluginInput?.artifact
21+
const index = (recipe.artifacts?.typed ?? []).findIndex((artifact) => artifact.name === artifactName)
22+
const declaration = index >= 0 ? recipe.artifacts?.typed?.[index] : undefined
23+
if (!declaration) throw new RecipePhasedPluginInputError("Phased plugin input references an unknown typed artifact.", "collect", { artifact: artifactName })
24+
const artifact = await collectRecipeTypedArtifact(runtime, declaration, index, declaration.path, { redact: false })
25+
if (artifact.status !== "collected" || artifact.parsedJson === undefined) {
26+
throw new RecipePhasedPluginInputError("Phased plugin input artifact could not be collected as JSON.", "collect", { artifact: artifactName, status: artifact.status })
27+
}
28+
return artifact
29+
}
30+
31+
export function projectPhasedPluginPackages(input: unknown, projection: WorkspaceRecipePluginPackageProjection): ProjectedPluginPackageDescriptor[] {
32+
const selected = pointerValue(input, projection.items)
33+
if (!selected.found || !Array.isArray(selected.value)) throw new RecipePhasedPluginInputError("Phased plugin package items pointer must resolve to an array.", "project", { pointer: projection.items })
34+
if (selected.value.length > MAX_PHASED_PLUGINS) throw new RecipePhasedPluginInputError(`Phased plugin input exceeds the ${MAX_PHASED_PLUGINS}-plugin bound.`, "project")
35+
const resolver = projection.resolver ?? "immutable-archive"
36+
const slugs = new Set<string>()
37+
return selected.value.map((item, index) => {
38+
const slug = projectedString(item, projection.map.slug, `items[${index}].slug`, true)
39+
const source = projectedString(item, projection.map.source, `items[${index}].source`, resolver === "immutable-archive")
40+
const sha256 = projectedString(item, projection.map.sha256, `items[${index}].sha256`, resolver === "immutable-archive")
41+
const pluginFile = projectedString(item, projection.map.pluginFile, `items[${index}].pluginFile`, false)
42+
const activate = projectedBoolean(item, projection.map.activate, `items[${index}].activate`)
43+
const loadAs = projectedString(item, projection.map.loadAs, `items[${index}].loadAs`, false)
44+
if (!/^[a-z0-9][a-z0-9-_]*$/i.test(slug)) throw new RecipePhasedPluginInputError(`items[${index}].slug is invalid.`, "project", { slug })
45+
if (slugs.has(slug)) throw new RecipePhasedPluginInputError(`items[${index}].slug duplicates ${slug}.`, "project", { slug })
46+
if (sha256 && !/^[a-f0-9]{64}$/i.test(sha256)) throw new RecipePhasedPluginInputError(`items[${index}].sha256 must be a 64-character hexadecimal digest.`, "project")
47+
if (pluginFile && (!pluginFile.startsWith(`${slug}/`) || !/^[^/][^:]*\.php$/.test(pluginFile) || pluginFile.includes(".."))) throw new RecipePhasedPluginInputError(`items[${index}].pluginFile must stay under ${slug}/.`, "project")
48+
if (loadAs && loadAs !== "plugin") throw new RecipePhasedPluginInputError(`items[${index}].loadAs must be plugin.`, "project")
49+
if (source) {
50+
try {
51+
if (recipeSource(source, sha256).type === "local") throw new Error("local source")
52+
} catch (error) {
53+
throw new RecipePhasedPluginInputError(`items[${index}].source must be an HTTPS plugin archive.`, "project", { source }, error)
54+
}
55+
}
56+
slugs.add(slug)
57+
return { slug, ...(source ? { source } : {}), ...(sha256 ? { sha256: sha256.toLowerCase() } : {}), ...(pluginFile ? { pluginFile } : {}), ...(activate !== undefined ? { activate } : {}), loadAs: "plugin" }
58+
})
59+
}
60+
61+
export async function resolvePhasedPluginPackages(descriptors: ProjectedPluginPackageDescriptor[], projection: WorkspaceRecipePluginPackageProjection, fetcher = fetch): Promise<WorkspaceRecipeExtraPlugin[]> {
62+
const resolver = projection.resolver ?? "immutable-archive"
63+
if (resolver === "immutable-archive") return descriptors.map((descriptor) => ({ ...descriptor, activate: descriptor.activate !== false })) as WorkspaceRecipeExtraPlugin[]
64+
const plugins: WorkspaceRecipeExtraPlugin[] = []
65+
for (const descriptor of descriptors) {
66+
const infoUrl = new URL("https://api.wordpress.org/plugins/info/1.2/")
67+
infoUrl.searchParams.set("action", "plugin_information")
68+
infoUrl.searchParams.set("request[slug]", descriptor.slug)
69+
const response = await fetcher(infoUrl, { redirect: "error", signal: AbortSignal.timeout(30_000) })
70+
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 })
71+
const contentLength = Number(response.headers.get("content-length") || 0)
72+
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 })
73+
const responseText = await response.text()
74+
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 })
75+
let info: { version?: unknown; download_link?: unknown }
76+
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) }
77+
const version = typeof info.version === "string" ? info.version : ""
78+
const source = typeof info.download_link === "string" ? info.download_link : ""
79+
let sourceUrl: URL
80+
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) }
81+
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 })
82+
plugins.push({
83+
source: sourceUrl.toString(),
84+
slug: descriptor.slug,
85+
...(descriptor.pluginFile ? { pluginFile: descriptor.pluginFile } : {}),
86+
activate: descriptor.activate !== false,
87+
loadAs: "plugin",
88+
metadata: { phased_input: { resolver, version, source_url: sourceUrl.toString() } },
89+
})
90+
}
91+
return plugins
92+
}
93+
94+
function projectedString(item: unknown, pointer: string | undefined, field: string, required: boolean): string {
95+
if (!pointer) {
96+
if (required) throw new RecipePhasedPluginInputError(`${field} mapping is required.`, "project")
97+
return ""
98+
}
99+
const selected = pointerValue(item, pointer)
100+
if (!selected.found || typeof selected.value !== "string" || (required && !selected.value)) throw new RecipePhasedPluginInputError(`${field} must resolve to${required ? " a non-empty" : ""} string.`, "project", { pointer })
101+
return selected.value
102+
}
103+
104+
function projectedBoolean(item: unknown, pointer: string | undefined, field: string): boolean | undefined {
105+
if (!pointer) return undefined
106+
const selected = pointerValue(item, pointer)
107+
if (!selected.found || typeof selected.value !== "boolean") throw new RecipePhasedPluginInputError(`${field} must resolve to a boolean.`, "project", { pointer })
108+
return selected.value
109+
}
110+
111+
function pointerValue(value: unknown, pointer: string): { found: boolean; value?: unknown } {
112+
if (pointer === "") return { found: true, value }
113+
if (!pointer.startsWith("/") || /~(?:[^01]|$)/.test(pointer)) return { found: false }
114+
let current = value
115+
for (const encoded of pointer.slice(1).split("/")) {
116+
const segment = encoded.replace(/~1/g, "/").replace(/~0/g, "~")
117+
if (Array.isArray(current)) {
118+
if (!/^(0|[1-9]\d*)$/.test(segment) || Number(segment) >= current.length) return { found: false }
119+
current = current[Number(segment)]
120+
} else if (current && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, segment)) current = (current as Record<string, unknown>)[segment]
121+
else return { found: false }
122+
}
123+
return { found: true, value: current }
124+
}

‎packages/cli/src/commands/recipe-run-output.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { setTimeout as delay } from "node:timers/promises"
2+
import { mkdir, writeFile } from "node:fs/promises"
3+
import { dirname } from "node:path"
24
import type { ExecutionResult, RecipeRunSummary, RuntimeRunRecord } from "@automattic/wp-codebox-core"
35
import { boundedExecutionResultsForArtifacts } from "@automattic/wp-codebox-core/internals"
46
import { serializeError } from "../output.js"
@@ -95,8 +97,15 @@ function hasTerminalRecipePhaseFailure(output: RecipeRunOutput): boolean {
9597
return output.phaseEvidence?.some((phase) => phase.status === "failed" && hasSerializedErrorCode(phase.error, "recipe-phase-failed")) ?? false
9698
}
9799

98-
export async function writeRecipeJsonOutput(output: unknown): Promise<void> {
99-
await writeStdout(`${JSON.stringify(boundedRecipeJsonOutput(output), null, 2)}\n`)
100+
export async function writeRecipeJsonOutput(output: unknown, outputPath?: string): Promise<void> {
101+
const json = `${JSON.stringify(boundedRecipeJsonOutput(output), null, 2)}\n`
102+
if (outputPath) {
103+
await mkdir(dirname(outputPath), { recursive: true })
104+
await writeFile(outputPath, json)
105+
return
106+
}
107+
108+
await writeStdout(json)
100109
}
101110

102111
export function boundedRecipeJsonOutput(output: unknown): unknown {

‎packages/cli/src/commands/recipe-run-types.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { RecipeSourceProvenance } from "../recipe-sources.js"
1010

1111
export interface RecipeRunOptions {
1212
recipePath: string
13+
outputPath?: string
1314
artifactsDirectory?: string
1415
runRegistryDirectory?: string
1516
previewHoldSeconds?: number
@@ -317,7 +318,9 @@ export interface RecipeDiagnosticArtifactRef {
317318
sha256?: string
318319
}
319320

320-
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"
321+
export type RecipePhasedPluginInputStage = "collect" | "project" | "resolve" | "mount" | "activate" | "readiness"
322+
323+
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"
321324

322325
export interface RecipePhaseEvidence {
323326
schema: "wp-codebox/recipe-phase-evidence/v1"

0 commit comments

Comments
 (0)