diff --git a/package-lock.json b/package-lock.json index cdb4d2b4..e9219c32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wp-codebox-workspace", - "version": "0.10.0", + "version": "0.12.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wp-codebox-workspace", - "version": "0.10.0", + "version": "0.12.23", "license": "ISC", "workspaces": [ "packages/*" @@ -15,7 +15,7 @@ "@automattic/wp-codebox-cli": "file:packages/cli", "@automattic/wp-codebox-core": "file:packages/runtime-core", "@automattic/wp-codebox-playground": "file:packages/runtime-playground", - "@php-wasm/node-8-4": "^3.1.36", + "@php-wasm/node-8-4": "^3.1.45", "@types/pngjs": "^6.0.5", "@wp-playground/cli": "^3.1.35", "@wp-playground/wordpress-builds": "^0.9.4", @@ -1192,12 +1192,12 @@ } }, "node_modules/@php-wasm/node-8-4": { - "version": "3.1.36", - "resolved": "https://registry.npmjs.org/@php-wasm/node-8-4/-/node-8-4-3.1.36.tgz", - "integrity": "sha512-Aq7uI9A/DspJmz3ciDHY9nAge+QYSfJaaMFN4KCEb4I3npIdiLF35/dHPY+aj7ZmMwwLPNsxEAMUN75av1MfYQ==", + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-4/-/node-8-4-3.1.45.tgz", + "integrity": "sha512-QElunKrFEpVa11Fdr6Afmw/gI/bCf5rxzAuXLBYfgghJP2w8sTT0M1ku1pocPEvMGeONbQgvHA06sDdKXhHEMA==", "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/universal": "3.1.36", + "@php-wasm/universal": "3.1.45", "wasm-feature-detect": "1.8.0" }, "engines": { @@ -1206,9 +1206,9 @@ } }, "node_modules/@php-wasm/node-8-4/node_modules/@php-wasm/logger": { - "version": "3.1.36", - "resolved": "https://registry.npmjs.org/@php-wasm/logger/-/logger-3.1.36.tgz", - "integrity": "sha512-Rol0+ZP0JzM1+LP11oP1AJ0G/qEZrbh3XJZwcpi5lhL78ZfKXix4hKAino2C3ObFZXr9I0FbwsG4rifez023lQ==", + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/logger/-/logger-3.1.45.tgz", + "integrity": "sha512-FYWGxaRZTM+aiRQ6/LlnFAdsixJ7VWQpM8Fe/61yWgpBamb1w3NEDRhYVR5DNjYWJVI5ha6pVxIsHtw018iA7g==", "license": "GPL-2.0-or-later", "engines": { "node": ">=20.10.0", @@ -1216,12 +1216,12 @@ } }, "node_modules/@php-wasm/node-8-4/node_modules/@php-wasm/progress": { - "version": "3.1.36", - "resolved": "https://registry.npmjs.org/@php-wasm/progress/-/progress-3.1.36.tgz", - "integrity": "sha512-2NchEvgF6KbNxYOo9PIOiKnJq93EYajLLhub9XK7g5mQmeA+bJg03Mn+QFN4k67sGsVKtgY4W7Wsg8IyoSGbqg==", + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/progress/-/progress-3.1.45.tgz", + "integrity": "sha512-rc/I9MSoqAPLRmrgArpWMrSq6s7R16ulrMjG3Ti5T4hfu+j2oxfBCHbrwzR57ITsgecjKZ+G9j/w+saQj9cgpQ==", "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/logger": "3.1.36" + "@php-wasm/logger": "3.1.45" }, "engines": { "node": ">=20.10.0", @@ -1229,24 +1229,24 @@ } }, "node_modules/@php-wasm/node-8-4/node_modules/@php-wasm/stream-compression": { - "version": "3.1.36", - "resolved": "https://registry.npmjs.org/@php-wasm/stream-compression/-/stream-compression-3.1.36.tgz", - "integrity": "sha512-6jsYce14wvQ7/ykCGURMaBTqM+edZTBQp0yMYLQWlJbD8DpYeaUkivkWEg0wsNd0ponNEK8jFWr1G5/QETtwxw==", + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/stream-compression/-/stream-compression-3.1.45.tgz", + "integrity": "sha512-zLU1AGNEg1tuwm93LHrL8OcV24LyNglTg/CXV5CVZiMPpg1wfIDHJy11qqjZM8iCIJk87qYQhkKOjTnGinAVDg==", "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/util": "3.1.36" + "@php-wasm/util": "3.1.45" } }, "node_modules/@php-wasm/node-8-4/node_modules/@php-wasm/universal": { - "version": "3.1.36", - "resolved": "https://registry.npmjs.org/@php-wasm/universal/-/universal-3.1.36.tgz", - "integrity": "sha512-1aEreyTpNVIBwMHbaWVgeNA8gUfqLPjtJYmtmqlyMounVtxvUGdiSCpIb2G40ZUv5X15FvI30dhnCV4izLJhZA==", + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/universal/-/universal-3.1.45.tgz", + "integrity": "sha512-3x0TWQg6NEeO+M/oPA3rAP0hJB2gnsqwkb2sUipz0RQQXRkWEJjdrT20UVmGN/dXILDWeQg/VGxZhCktoWZGkA==", "license": "GPL-2.0-or-later", "dependencies": { - "@php-wasm/logger": "3.1.36", - "@php-wasm/progress": "3.1.36", - "@php-wasm/stream-compression": "3.1.36", - "@php-wasm/util": "3.1.36", + "@php-wasm/logger": "3.1.45", + "@php-wasm/progress": "3.1.45", + "@php-wasm/stream-compression": "3.1.45", + "@php-wasm/util": "3.1.45", "ini": "4.1.2" }, "engines": { @@ -1255,9 +1255,9 @@ } }, "node_modules/@php-wasm/node-8-4/node_modules/@php-wasm/util": { - "version": "3.1.36", - "resolved": "https://registry.npmjs.org/@php-wasm/util/-/util-3.1.36.tgz", - "integrity": "sha512-gLimD7rMWQTLlKJwQmYDephM1/TdSj2IvXx+sy34da+H4H5lMrOfUHQ0SGsHNZIu/ZCjb6DlAzm6IqlKYnhP0A==", + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/util/-/util-3.1.45.tgz", + "integrity": "sha512-czXqXd2NJWkapV/zTr3WPSPdcgyI5KN7xSUusYpgkfljSovfvHOKKrTMQtbfPXRa9+MD8LmPQt+XStKPRp76Tg==", "engines": { "node": ">=20.10.0", "npm": ">=10.2.3" @@ -3928,7 +3928,7 @@ }, "packages/cli": { "name": "@automattic/wp-codebox-cli", - "version": "0.10.0", + "version": "0.12.23", "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", "@automattic/wp-codebox-playground": "file:../runtime-playground" @@ -3939,17 +3939,17 @@ }, "packages/runtime-core": { "name": "@automattic/wp-codebox-core", - "version": "0.10.0", + "version": "0.12.23", "dependencies": { "ajv": "^8.20.0" } }, "packages/runtime-playground": { "name": "@automattic/wp-codebox-playground", - "version": "0.10.0", + "version": "0.12.23", "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", - "@php-wasm/node": "^3.1.35", + "@php-wasm/node": "^3.1.45", "@php-wasm/universal": "^3.1.35", "@types/pngjs": "^6.0.5", "@wp-playground/blueprints": "^3.1.35", @@ -3962,6 +3962,226 @@ "pngjs": "^7.0.0" } }, + "packages/runtime-playground/node_modules/@php-wasm/cli-util": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/cli-util/-/cli-util-3.1.45.tgz", + "integrity": "sha512-+ht22B6KPUsn5ORtApE5xYq+uIb7/Qv/NSOxYgNGSWf3O1rpxkbOMeCxtE7H3dvazJIlSF+/nsVHpHkcTZGOpA==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/util": "3.1.45", + "fast-xml-parser": "^5.8.0", + "jsonc-parser": "3.3.1" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/logger": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/logger/-/logger-3.1.45.tgz", + "integrity": "sha512-FYWGxaRZTM+aiRQ6/LlnFAdsixJ7VWQpM8Fe/61yWgpBamb1w3NEDRhYVR5DNjYWJVI5ha6pVxIsHtw018iA7g==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/node": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/node/-/node-3.1.45.tgz", + "integrity": "sha512-IFnGdNsQ0g8RwpFzg3gAt8VckFwshQ7g2Izwt+i+wi2Rtnj54DcPZpfMwliiq+c1XU1//jtXHBdczbwR75yW7A==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/cli-util": "3.1.45", + "@php-wasm/logger": "3.1.45", + "@php-wasm/node-5-2": "3.1.45", + "@php-wasm/node-7-4": "3.1.45", + "@php-wasm/node-8-0": "3.1.45", + "@php-wasm/node-8-1": "3.1.45", + "@php-wasm/node-8-2": "3.1.45", + "@php-wasm/node-8-3": "3.1.45", + "@php-wasm/node-8-4": "3.1.45", + "@php-wasm/node-8-5": "3.1.45", + "@php-wasm/universal": "3.1.45", + "@php-wasm/util": "3.1.45", + "fs-ext-extra-prebuilt": "2.2.7", + "wasm-feature-detect": "1.8.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/node-5-2": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/node-5-2/-/node-5-2-3.1.45.tgz", + "integrity": "sha512-mKYOI8/eWE7Gu9wOOwtcxGrhOfCfGlIqT3Jr1RK0qCsZ/GkiW2/EalsNC5wSWbaL9qdh4JUbXUoF7mE3dc+KRQ==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.45", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/node-7-4": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/node-7-4/-/node-7-4-3.1.45.tgz", + "integrity": "sha512-3+VgXIL4y4Acb/OyNQAsIfz5I5GYlFmjw4M8l2Xx8TNBrixuOEO5vHmMoTssynZlmjoTLxBBzL2SaRHoubwi6g==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.45", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/node-8-0": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-0/-/node-8-0-3.1.45.tgz", + "integrity": "sha512-6yJaW+mxgBWAy80IYRU41bh1LH4WsuDgqEWiHSumJcKOcNljSedUbm+YuLTY+Pnsov7u5yzkaBskslxGXcOlIQ==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.45", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/node-8-1": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-1/-/node-8-1-3.1.45.tgz", + "integrity": "sha512-/5haLrmCySu40aDGtVAOukDBNYzCJ0JTXslXs36Rh8P9V2nlctVGO+Kp1m/74yCtdoJrZsRrBnVqKBCliVQjdg==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.45", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/node-8-2": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-2/-/node-8-2-3.1.45.tgz", + "integrity": "sha512-fGsSHw6OTnNyjr6k87OqV8SYqRZlDrWYVSXFkOywXUbBvyAYeuttxHffYC4VoRZdRz+Ygu+2k2aPE06jpTTBFw==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.45", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/node-8-3": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-3/-/node-8-3-3.1.45.tgz", + "integrity": "sha512-dq22w7k6zxSgRKEjFej2k2etHpWA/f6UZFQCwi0qPFTejlVRfGqJoY+6gwtF4PCCGXGD7J36XyRuSFuZz833MA==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.45", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/node-8-5": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-5/-/node-8-5-3.1.45.tgz", + "integrity": "sha512-FeKtaRMZorN1uRFbC6DILfv+Y9ImEMLjcpNVyT7zZ4WHPEf1yN0Q0Jo8VH9NNwCmkmiDAQN2gAIn3nKL8vsz+g==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.45", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/progress": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/progress/-/progress-3.1.45.tgz", + "integrity": "sha512-rc/I9MSoqAPLRmrgArpWMrSq6s7R16ulrMjG3Ti5T4hfu+j2oxfBCHbrwzR57ITsgecjKZ+G9j/w+saQj9cgpQ==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/logger": "3.1.45" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/stream-compression": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/stream-compression/-/stream-compression-3.1.45.tgz", + "integrity": "sha512-zLU1AGNEg1tuwm93LHrL8OcV24LyNglTg/CXV5CVZiMPpg1wfIDHJy11qqjZM8iCIJk87qYQhkKOjTnGinAVDg==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/util": "3.1.45" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/universal": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/universal/-/universal-3.1.45.tgz", + "integrity": "sha512-3x0TWQg6NEeO+M/oPA3rAP0hJB2gnsqwkb2sUipz0RQQXRkWEJjdrT20UVmGN/dXILDWeQg/VGxZhCktoWZGkA==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/logger": "3.1.45", + "@php-wasm/progress": "3.1.45", + "@php-wasm/stream-compression": "3.1.45", + "@php-wasm/util": "3.1.45", + "ini": "4.1.2" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/@php-wasm/util": { + "version": "3.1.45", + "resolved": "https://registry.npmjs.org/@php-wasm/util/-/util-3.1.45.tgz", + "integrity": "sha512-czXqXd2NJWkapV/zTr3WPSPdcgyI5KN7xSUusYpgkfljSovfvHOKKrTMQtbfPXRa9+MD8LmPQt+XStKPRp76Tg==", + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "packages/runtime-playground/node_modules/fs-ext-extra-prebuilt": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/fs-ext-extra-prebuilt/-/fs-ext-extra-prebuilt-2.2.7.tgz", + "integrity": "sha512-Q7rayYRBDIvDF01HWOwSSjoaP+05N1g+o3BXL1Zf8Frw2JkjSmi4EtvCBITuW30l6hB2m2TW1pehdh8wyU/+gw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.24.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "packages/runtime-playground/node_modules/ini": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", + "integrity": "sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "packages/wordpress-plugin": { "name": "wp-codebox-wordpress-plugin", "version": "0.8.0" diff --git a/package.json b/package.json index 1e931b09..00cc48a4 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "test:playground-readonly-mounts": "tsx tests/playground-readonly-mounts.test.ts", "test:playground-readonly-mounts-integration": "tsx tests/playground-readonly-mounts-integration.test.ts", "test:playground-phpunit-readonly-cache-integration": "tsx tests/playground-phpunit-readonly-cache.integration.test.ts", + "test:php-wasm-extension-manifests": "tsx tests/php-wasm-extension-manifests.test.ts", "test:browser-task-builder": "tsx tests/browser-task-builder.test.ts", "test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts", "test:browser-runtime-file-ops": "tsx tests/browser-runtime-file-ops.test.ts", @@ -250,7 +251,7 @@ "@automattic/wp-codebox-cli": "file:packages/cli", "@automattic/wp-codebox-core": "file:packages/runtime-core", "@automattic/wp-codebox-playground": "file:packages/runtime-playground", - "@php-wasm/node-8-4": "^3.1.36", + "@php-wasm/node-8-4": "^3.1.45", "@types/pngjs": "^6.0.5", "@wp-playground/cli": "^3.1.35", "@wp-playground/wordpress-builds": "^0.9.4", diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index ef63f13d..5579d035 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto" import { readFileSync } from "node:fs" import { mkdir, readFile, writeFile } from "node:fs/promises" import { createRequire } from "node:module" -import { basename, dirname, join, resolve } from "node:path" +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" import { DEFAULT_WORDPRESS_VERSION, createRuntime, normalizeRecipeRunSummary, normalizeRuntimeEnvRecord, parseCommandOptions, validateRuntimePolicy, type ArtifactBundle, type ArtifactPackageIdentity, type ArtifactPackageProvenance, type Runtime, type RuntimeAssetSpec, type RuntimePolicy, type RuntimePreviewSpec, type RuntimeRunRegistry, type WorkspaceRecipe, type WorkspaceRecipeComponentManifest, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase } from "@automattic/wp-codebox-core" import { stripUndefined } from "@automattic/wp-codebox-core/internals" import { recipeExecutionSpec, sandboxWorkspaceContract } from "../agent-sandbox.js" @@ -204,6 +204,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe wordpressInstallMode: plan.runtime.wordpressInstallMode, blueprint: plan.runtime.blueprint, assets: resolveRecipeRuntimeAssets(recipe, recipeDirectory), + extensions: resolveRecipeRuntimeExtensionManifests(recipe, recipeDirectory), } const effectivePreview = effectiveRecipePreview(recipe.runtime?.preview, options) const runtimeCreateSpec = { @@ -655,6 +656,33 @@ function resolveRecipeRuntimeAssets(recipe: WorkspaceRecipe, recipeDirectory: st } } +export function resolveRecipeRuntimeExtensionManifests(recipe: WorkspaceRecipe, recipeDirectory: string): Array<{ manifest: string }> | undefined { + const extensions = recipe.runtime?.extensions + if (!extensions || extensions.length === 0) { + return undefined + } + + const root = resolve(recipeDirectory) + return extensions.map((extension, index) => { + const manifest = extension.manifest.trim() + if (!manifest || manifest.includes("\0")) { + throw new Error(`Recipe runtime extension ${index} requires a non-empty manifest path or HTTPS URL`) + } + if (/^https:\/\//i.test(manifest)) { + return { manifest } + } + if (isAbsolute(manifest)) { + throw new Error(`Recipe runtime extension ${index} manifest must be an HTTPS URL or a recipe-local path`) + } + const resolved = resolve(root, manifest) + const pathFromRoot = relative(root, resolved) + if (!pathFromRoot || pathFromRoot.startsWith("..") || isAbsolute(pathFromRoot)) { + throw new Error(`Recipe runtime extension ${index} manifest resolves outside the recipe directory`) + } + return { manifest: resolved } + }) +} + function isUrl(value: string): boolean { return /^https?:\/\//i.test(value) } diff --git a/packages/cli/src/recipe-dry-run.ts b/packages/cli/src/recipe-dry-run.ts index 344c9ec4..97f86b86 100644 --- a/packages/cli/src/recipe-dry-run.ts +++ b/packages/cli/src/recipe-dry-run.ts @@ -56,6 +56,7 @@ export interface RecipePlan { phpVersion?: string wordpressInstallMode?: RuntimeWordPressInstallMode blueprint: unknown + extensions?: Array<{ manifest: string }> } distribution?: RecipeDryRunDistribution artifacts: { @@ -440,6 +441,7 @@ export async function planWorkspaceRecipe(recipe: WorkspaceRecipe, recipeDirecto wp: recipe.runtime?.wp ?? context.defaultWordPressVersion, ...(recipe.runtime?.phpVersion ? { phpVersion: recipe.runtime.phpVersion } : {}), ...(recipe.runtime?.wordpressInstallMode ? { wordpressInstallMode: recipe.runtime.wordpressInstallMode } : {}), + ...(recipe.runtime?.extensions ? { extensions: recipe.runtime.extensions } : {}), blueprint: recipeBlueprintWithBootActivePlugins(recipe.runtime?.blueprint, extraPlugins), }, ...(distribution ? { distribution } : {}), diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index 79b0a16c..1b38f9a7 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -136,6 +136,7 @@ export function validateWorkspaceRecipeShape(recipe: WorkspaceRecipe, recipePath validateRecipeRuntimeBackendPackage(recipe.runtime?.backendPackage, recipePath) validateRecipeRuntimeOverlays(recipe.runtime?.overlays, recipePath) validateRecipeRuntimeAssets(recipe.runtime?.assets, recipePath) + validateRecipeRuntimeExtensions(recipe.runtime?.extensions, recipePath) validateRecipeRuntimeWordPressInstallMode(recipe.runtime?.wordpressInstallMode, recipePath) validateRecipeRuntimePreview(recipe.runtime?.preview, recipePath) validateRecipeMounts(recipe.inputs?.mounts, "mounts", recipePath) @@ -338,6 +339,23 @@ function validateRecipeRuntimeAssets(assets: RuntimeAssetSpec | undefined, recip } } +function validateRecipeRuntimeExtensions(extensions: NonNullable["extensions"] | undefined, recipePath: string): void { + if (extensions === undefined) { + return + } + if (!Array.isArray(extensions)) { + throw new Error(`Recipe runtime extensions must be an array: ${recipePath}`) + } + for (const [index, extension] of extensions.entries()) { + if (!extension || typeof extension !== "object" || Array.isArray(extension) || typeof extension.manifest !== "string" || !extension.manifest.trim() || extension.manifest.includes("\0")) { + throw new Error(`Recipe runtime extensions[${index}] requires a non-empty manifest: ${recipePath}`) + } + if (/^[a-z][a-z0-9+.-]*:/i.test(extension.manifest) && !/^https:\/\//i.test(extension.manifest)) { + throw new Error(`Recipe runtime extensions[${index}] manifest URL must use HTTPS: ${recipePath}`) + } + } +} + function validateRecipeRuntimeWordPressInstallMode(mode: NonNullable["wordpressInstallMode"] | undefined, recipePath: string): void { if (mode === undefined) { return diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index 7e8d48b6..6f47716a 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -126,6 +126,11 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche blueprint: { type: "object" }, preview: { $ref: "#/$defs/runtimePreview" }, assets: { $ref: "#/$defs/runtimeAssets" }, + extensions: { + type: "array", + description: "External PHP.wasm extension manifests loaded before PHP starts. External extensions require a JSPI runtime.", + items: { $ref: "#/$defs/phpWasmExtensionManifest" }, + }, backendPackage: { $ref: "#/$defs/runtimeBackendPackage" }, stack: { $ref: "#/$defs/runtimeStack" }, overlays: { @@ -510,6 +515,14 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche }, }, }, + phpWasmExtensionManifest: { + type: "object", + additionalProperties: false, + required: ["manifest"], + properties: { + manifest: { type: "string", minLength: 1, description: "HTTPS URL or recipe-local path to a PHP.wasm extension manifest." }, + }, + }, mount: { type: "object", additionalProperties: false, diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index 185a22e1..ecef0df3 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -3,7 +3,7 @@ import type { RuntimePolicy } from "./runtime-policy.js" import { SANDBOX_WORKSPACE_ROOT } from "./runtime-action-adapter.js" import type { ArtifactFileDigest, ArtifactManifestFile, ArtifactSpec, ArtifactViewerMetadata } from "./artifact-manifest.js" import type { HostToolDefinition, HostToolRegistry } from "./host-tool-registry.js" -import type { BackendNeutralRuntimeProvenance, RuntimeWordPressAssetSpec, RuntimeWordPressEnvironmentSpec, RuntimeWordPressInstallModeContract, RuntimeWordPressProvenance } from "./runtime-neutral-contracts.js" +import type { BackendNeutralRuntimeProvenance, RuntimePHPWasmExtensionManifest, RuntimeWordPressAssetSpec, RuntimeWordPressEnvironmentSpec, RuntimeWordPressInstallModeContract, RuntimeWordPressProvenance } from "./runtime-neutral-contracts.js" import type { RUNTIME_EPISODE_ACTION_SCHEMA, RUNTIME_EPISODE_OBSERVATION_SCHEMA, @@ -168,6 +168,9 @@ export interface WorkspaceRecipeRuntimeBackendPackage { metadata?: Record } +/** A recipe-owned external PHP.wasm extension manifest loaded before PHP starts. */ +export interface WorkspaceRecipePHPWasmExtensionManifest extends RuntimePHPWasmExtensionManifest {} + export interface WorkspaceRecipeDistributionSourceMount extends WorkspaceRecipeMount { role?: "wordpress-root" | "dependency" | "fixtures" | (string & {}) ref?: string @@ -581,6 +584,7 @@ export interface WorkspaceRecipe { blueprint?: unknown preview?: RuntimePreviewSpec assets?: RuntimeAssetSpec + extensions?: WorkspaceRecipePHPWasmExtensionManifest[] backendPackage?: WorkspaceRecipeRuntimeBackendPackage stack?: WorkspaceRecipeRuntimeStack overlays?: WorkspaceRecipeRuntimeOverlay[] diff --git a/packages/runtime-core/src/runtime-neutral-contracts.ts b/packages/runtime-core/src/runtime-neutral-contracts.ts index 6ad37b50..af4a091c 100644 --- a/packages/runtime-core/src/runtime-neutral-contracts.ts +++ b/packages/runtime-core/src/runtime-neutral-contracts.ts @@ -52,11 +52,17 @@ export interface RuntimeWordPressAssetSpec extends BackendNeutralRuntimeAssetSpe wordpressZip?: string } +/** A startup-time PHP.wasm extension manifest. External extensions are JSPI-only. */ +export interface RuntimePHPWasmExtensionManifest { + manifest: string +} + export interface RuntimeWordPressEnvironmentSpec extends BackendNeutralEnvironmentSpec { blueprint?: unknown phpVersion?: string assets?: RuntimeWordPressAssetSpec wordpressInstallMode?: RuntimeWordPressInstallModeContract + extensions?: RuntimePHPWasmExtensionManifest[] } export type BackendNeutralReplayStatus = "metadata-only" | "partial-replay" | "replayable-runtime-state" | "runtime-state-artifact" | "not-replayable" | (string & {}) @@ -109,6 +115,18 @@ export function normalizeRuntimeWordPressEnvironmentSpec(input: unknown): Runtim phpVersion: optionalString(value.phpVersion, "environment.phpVersion"), assets: normalizeRuntimeWordPressAssetSpec(value.assets), wordpressInstallMode: optionalString(value.wordpressInstallMode, "environment.wordpressInstallMode") as RuntimeWordPressInstallModeContract | undefined, + extensions: normalizeRuntimePHPWasmExtensionManifests(value.extensions), + }) +} + +function normalizeRuntimePHPWasmExtensionManifests(input: unknown): RuntimePHPWasmExtensionManifest[] | undefined { + if (input === undefined) return undefined + if (!Array.isArray(input)) throw new Error("environment.extensions must be an array.") + return input.map((entry, index) => { + const value = requireObject(entry, `environment.extensions[${index}]`) as Partial + const manifest = requiredString(value.manifest, `environment.extensions[${index}].manifest`) + if (manifest.includes("\0")) throw new Error(`environment.extensions[${index}].manifest must not contain a null byte.`) + return { manifest } }) } diff --git a/packages/runtime-playground/package.json b/packages/runtime-playground/package.json index 702ac4c6..02f905a1 100644 --- a/packages/runtime-playground/package.json +++ b/packages/runtime-playground/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", - "@php-wasm/node": "^3.1.35", + "@php-wasm/node": "^3.1.45", "@php-wasm/universal": "^3.1.35", "@wp-playground/blueprints": "^3.1.35", "@wp-playground/cli": "^3.1.35", diff --git a/packages/runtime-playground/src/php-wasm-preflight.ts b/packages/runtime-playground/src/php-wasm-preflight.ts index 860c0312..d63d3fee 100644 --- a/packages/runtime-playground/src/php-wasm-preflight.ts +++ b/packages/runtime-playground/src/php-wasm-preflight.ts @@ -25,6 +25,27 @@ export interface PhpWasmRuntimeAssetPreflightOptions { mode?: "jspi" | "asyncify" } +export class PhpWasmExternalExtensionCapabilityError extends Error { + readonly code = "wp-codebox-php-wasm-external-extensions-require-jspi" + readonly diagnostic: { capability: "jspi"; selectedMode: "asyncify"; message: string } + + constructor() { + const message = "External PHP.wasm extension manifests require a JSPI runtime; the selected runtime uses Asyncify." + super(message) + this.name = "PhpWasmExternalExtensionCapabilityError" + this.diagnostic = { capability: "jspi", selectedMode: "asyncify", message } + } +} + +export async function assertPhpWasmExternalExtensionsSupported(extensions: readonly unknown[] | undefined, mode?: "jspi" | "asyncify"): Promise { + if (!extensions || extensions.length === 0) { + return + } + if ((mode ?? phpWasmModeFromEnv() ?? await selectedPhpWasmMode()) !== "jspi") { + throw new PhpWasmExternalExtensionCapabilityError() + } +} + const repairHint = "Repair the PHP wasm runtime package by reinstalling dependencies, for example: remove node_modules and package-lock drift, then run npm install; if using a package cache, clear the broken @php-wasm package cache first." const compiledWasmCache = new Map() const requireFromHere = createRequire(import.meta.url) diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index f4f3bbe9..749f1d67 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -262,7 +262,9 @@ class PreviewLeaseProbeError extends Error { } export function shouldUseProgrammaticPlaygroundRunner(spec: RuntimeCreateSpec, options: PlaygroundCliStartupOptions = {}): boolean { - return !options.cliModule && Boolean(spec.environment.assets?.wordpressDirectory) && Boolean(runtimeBootstrapPhpIniEntries(spec)) + return !options.cliModule + && Boolean(spec.environment.assets?.wordpressDirectory) + && (Boolean(runtimeBootstrapPhpIniEntries(spec)) || Boolean(spec.environment.extensions?.length)) } async function pluginRuntimeBootstrapSharedMount(spec: RuntimeCreateSpec): Promise<{ hostPath: string; vfsPath: string } | undefined> { diff --git a/packages/runtime-playground/src/programmatic-playground-runner.ts b/packages/runtime-playground/src/programmatic-playground-runner.ts index 651750e9..09b55206 100644 --- a/packages/runtime-playground/src/programmatic-playground-runner.ts +++ b/packages/runtime-playground/src/programmatic-playground-runner.ts @@ -5,11 +5,12 @@ import type { MountSpec, RuntimeCreateSpec } from "@automattic/wp-codebox-core" import { createServer as createHttpServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http" import { dirname } from "node:path" import { playgroundBlueprint } from "./blueprint.js" +import { assertPhpWasmExternalExtensionsSupported } from "./php-wasm-preflight.js" import type { PlaygroundCliServer, PlaygroundServerRunResponse } from "./preview-server.js" const { createNodeFsMountHandler, loadNodeRuntime } = PHPWasmNode as unknown as { createNodeFsMountHandler(localPath: string): unknown - loadNodeRuntime(phpVersion: AllPHPVersion, options?: { followSymlinks?: boolean; emscriptenOptions?: { processId?: number } }): Promise + loadNodeRuntime(phpVersion: AllPHPVersion, options?: { followSymlinks?: boolean; emscriptenOptions?: { processId?: number }; extensions?: Array<{ source: { format: "manifest"; manifestUrl: string } }> }): Promise } type AllPHPVersion = "8.5" | "8.4" | "8.3" | "8.2" | "8.1" | "8.0" | "7.4" | "5.2" @@ -40,16 +41,14 @@ export interface ProgrammaticPlaygroundStartupOptions { export async function startProgrammaticPlaygroundServer(spec: RuntimeCreateSpec, mounts: MountSpec[], options: ProgrammaticPlaygroundStartupOptions): Promise { const phpVersion = (spec.environment.phpVersion ?? "8.4") as AllPHPVersion + await assertPhpWasmExternalExtensionsSupported(spec.environment.extensions) let nextProcessId = 1 const phpIniEntries = { ...options.bootstrapIniEntries, ...options.phpIniEntries, } const requestHandler = await bootWordPressAndRequestHandler({ - createPhpRuntime: () => loadNodeRuntime(phpVersion, { - followSymlinks: true, - emscriptenOptions: { processId: nextProcessId++ }, - }), + createPhpRuntime: () => loadNodeRuntime(phpVersion, programmaticNodeRuntimeOptions(spec, nextProcessId++)), phpVersion, siteUrl: spec.preview?.siteUrl ?? "http://127.0.0.1", documentRoot: "/wordpress", @@ -101,6 +100,15 @@ export async function startProgrammaticPlaygroundServer(spec: RuntimeCreateSpec, } } +export function programmaticNodeRuntimeOptions(spec: RuntimeCreateSpec, processId: number): { followSymlinks: true; emscriptenOptions: { processId: number }; extensions?: Array<{ source: { format: "manifest"; manifestUrl: string } }> } { + const extensions = spec.environment.extensions?.map((extension) => ({ source: { format: "manifest" as const, manifestUrl: extension.manifest } })) + return { + followSymlinks: true, + emscriptenOptions: { processId }, + ...(extensions && extensions.length > 0 ? { extensions } : {}), + } +} + function autoPrependPhp(spec: RuntimeCreateSpec): string { const recipe = spec.metadata?.recipe if (!recipe || typeof recipe !== "object" || Array.isArray(recipe)) { diff --git a/tests/php-wasm-extension-manifests.test.ts b/tests/php-wasm-extension-manifests.test.ts new file mode 100644 index 00000000..a516d1a6 --- /dev/null +++ b/tests/php-wasm-extension-manifests.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict" + +import { normalizeRuntimeWordPressEnvironmentSpec, validateWorkspaceRecipeJsonSchema, type WorkspaceRecipe } from "../packages/runtime-core/src/index.js" +import { resolveRecipeRuntimeExtensionManifests } from "../packages/cli/src/commands/recipe-run.js" +import { assertPhpWasmExternalExtensionsSupported, PhpWasmExternalExtensionCapabilityError } from "../packages/runtime-playground/src/php-wasm-preflight.js" +import { programmaticNodeRuntimeOptions } from "../packages/runtime-playground/src/programmatic-playground-runner.js" + +const recipe: WorkspaceRecipe = { + schema: "wp-codebox/workspace-recipe/v1", + runtime: { extensions: [{ manifest: "extensions/parser/manifest.json" }] }, + workflow: { steps: [{ command: "wordpress.run-php" }] }, +} + +assert.equal(validateWorkspaceRecipeJsonSchema(recipe).valid, true) +assert.equal(validateWorkspaceRecipeJsonSchema({ ...recipe, runtime: { extensions: [{ manifest: "", unexpected: true }] } }).valid, false) +assert.deepEqual(normalizeRuntimeWordPressEnvironmentSpec({ kind: "wordpress", extensions: recipe.runtime?.extensions }), { kind: "wordpress", extensions: [{ manifest: "extensions/parser/manifest.json" }] }) + +const resolved = resolveRecipeRuntimeExtensionManifests(recipe, "/tmp/recipe") +assert.deepEqual(resolved, [{ manifest: "/tmp/recipe/extensions/parser/manifest.json" }]) +assert.throws(() => resolveRecipeRuntimeExtensionManifests({ ...recipe, runtime: { extensions: [{ manifest: "../manifest.json" }] } }, "/tmp/recipe"), /outside the recipe directory/) + +const runtimeSpec = { + backend: "wordpress-playground", + environment: { kind: "wordpress", extensions: resolved }, + policy: { network: "deny", filesystem: "readwrite-mounts", commands: [], secrets: "none", approvals: "never" }, +} as const +const firstInstance = programmaticNodeRuntimeOptions(runtimeSpec, 1) +const pooledInstance = programmaticNodeRuntimeOptions(runtimeSpec, 2) +assert.deepEqual(firstInstance.extensions, [{ source: { format: "manifest", manifestUrl: "/tmp/recipe/extensions/parser/manifest.json" } }]) +assert.deepEqual(pooledInstance.extensions, firstInstance.extensions) +assert.equal(firstInstance.emscriptenOptions.processId, 1) +assert.equal(pooledInstance.emscriptenOptions.processId, 2) + +await assert.rejects( + assertPhpWasmExternalExtensionsSupported(resolved, "asyncify"), + (error: unknown) => error instanceof PhpWasmExternalExtensionCapabilityError && error.message.includes("JSPI") && error.diagnostic.selectedMode === "asyncify", +) +await assert.doesNotReject(assertPhpWasmExternalExtensionsSupported(resolved, "jspi")) + +console.log("php wasm extension manifests ok")