From 076ef7aa02e0b28ef0432f581ba703e88fa14f4f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 13:39:53 -0400 Subject: [PATCH 1/9] feat(runtime): add native PHP backend --- docs/native-php-runtime.md | 62 ++++++++ npm-shrinkwrap.json | 14 ++ package.json | 13 +- packages/cli/package.json | 1 + packages/cli/src/recipe-backend-package.ts | 25 +++- packages/cli/src/runtime-backends.ts | 3 +- packages/cli/tsconfig.json | 1 + .../src/runtime-backend-resolver.ts | 2 + packages/runtime-native/package.json | 17 +++ packages/runtime-native/src/index.ts | 2 + packages/runtime-native/src/native-runtime.ts | 138 ++++++++++++++++++ packages/runtime-native/tsconfig.json | 6 + .../backend-package-adapter-registry-smoke.ts | 17 +++ scripts/package-release-artifact.ts | 3 +- scripts/runtime-backend-registry-smoke.ts | 7 +- tests/native-runtime.test.ts | 72 +++++++++ tests/public-api-contract.test.ts | 1 + tests/root-package-boundary.test.mjs | 2 +- tsconfig.json | 1 + 19 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 docs/native-php-runtime.md create mode 100644 packages/runtime-native/package.json create mode 100644 packages/runtime-native/src/index.ts create mode 100644 packages/runtime-native/src/native-runtime.ts create mode 100644 packages/runtime-native/tsconfig.json create mode 100644 tests/native-runtime.test.ts diff --git a/docs/native-php-runtime.md b/docs/native-php-runtime.md new file mode 100644 index 000000000..8df70ec94 --- /dev/null +++ b/docs/native-php-runtime.md @@ -0,0 +1,62 @@ +# Native PHP Runtime Adapter + +`runtime.backend: "wordpress-native"` selects the native adapter without changing +backend-neutral recipe command IDs. The adapter never substitutes host PHP, +ambient credentials, or production state when a contained driver is unavailable. + +## Driver Contract + +The CLI loads an adapter-owned contained driver from `runtime.backendPackage` when +the backend is `wordpress-native`. Its package uses `kind: "native"` and exports +`createNativeRuntimeDriver()`. The driver must report a digest-pinned container +image, PHP version and SAPI, persistent enabled OPcache evidence, at least two HTTP +workers, and a disposable managed-runtime-service database. Startup rejects +incomplete evidence and destroys a partially created runtime. + +Before accepting commands, the driver persists +`wp-codebox/native-runtime-provenance/v1` in the run artifact directory and returns +its path and SHA-256. The evidence identifies the backend, PHP/SAPI, image digest, +OPcache configuration/status, worker model, managed database integration, and that +measurements are local representative evidence rather than production RUM. The +driver owns process and container lifecycle; `destroy()` is single-flight and the +adapter terminalizes the runtime even when driver cleanup reports an error. + +Browser commands remain backend-neutral. A native driver must expose the local +preview and authenticated fixture workflow through the existing browser-action +contract; it must not use caller browser credentials. + +`wordpress.browser-actions` must declare `auth=wordpress-admin` or +`auth=storage-state`. Native drivers accept only runtime-generated fixture state; +they must not import a caller profile or ambient browser credentials. + +## HTTP Concurrency + +The driver records its resolved worker count and worker model in +`provenance.httpConcurrency`. This is local representative evidence, not +production RUM. Benchmark workloads should record cold startup, warm no-op PHP, +and a dynamic WordPress request for both `wordpress-playground` and +`wordpress-native` using `wordpress.bench`. + +The native provenance contract records that all three cases are covered. Cold +startup crosses a worker/process boundary, warm no-op PHP retains the shared +OPcache, and the dynamic request exercises WordPress routing. These are local +representative measurements, not production RUM. + +## Example selection + +```json +{ + "runtime": { + "backend": "wordpress-native", + "backendPackage": { + "kind": "native", + "source": "./contained-native-driver" + } + } +} +``` + +The native package is responsible for translating the existing backend-neutral +`wordpress.*` commands, including browser actions and fixture authentication, into +its contained runtime. It must not read host PHP configuration, browser profiles, +ambient credentials, or production state. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index a10b58eb4..e31ad1e6f 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -12,12 +12,14 @@ "workspaces": [ "packages/cli", "packages/runtime-core", + "packages/runtime-native", "packages/runtime-playground", "packages/wordpress-plugin" ], "dependencies": { "@automattic/wp-codebox-cli": "file:packages/cli", "@automattic/wp-codebox-core": "file:packages/runtime-core", + "@automattic/wp-codebox-native": "file:packages/runtime-native", "@automattic/wp-codebox-playground": "file:packages/runtime-playground", "@php-wasm/node-8-3": "file:runtime-overlays/php-wasm-node-8-3", "@php-wasm/node-8-4": "3.1.46", @@ -223,6 +225,10 @@ "resolved": "packages/runtime-core", "link": true }, + "node_modules/@automattic/wp-codebox-native": { + "resolved": "packages/runtime-native", + "link": true + }, "node_modules/@automattic/wp-codebox-playground": { "resolved": "packages/runtime-playground", "link": true @@ -4797,6 +4803,7 @@ "version": "0.26.8", "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", + "@automattic/wp-codebox-native": "file:../runtime-native", "@automattic/wp-codebox-playground": "file:../runtime-playground" }, "bin": { @@ -4810,6 +4817,13 @@ "ajv": "^8.20.0" } }, + "packages/runtime-native": { + "name": "@automattic/wp-codebox-native", + "version": "0.26.8", + "dependencies": { + "@automattic/wp-codebox-core": "file:../runtime-core" + } + }, "packages/runtime-playground": { "name": "@automattic/wp-codebox-playground", "version": "0.26.8", diff --git a/package.json b/package.json index 3c72d3790..227b6a9f6 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,10 @@ "types": "./packages/runtime-playground/dist/public.d.ts", "import": "./packages/runtime-playground/dist/public.js" }, + "./native": { + "types": "./packages/runtime-native/dist/index.d.ts", + "import": "./packages/runtime-native/dist/index.js" + }, "./cli": { "types": "./packages/cli/dist/index.d.ts", "import": "./packages/cli/dist/index.js" @@ -75,6 +79,8 @@ "files": [ "packages/runtime-core/dist", "packages/runtime-core/package.json", + "packages/runtime-native/dist", + "packages/runtime-native/package.json", "packages/runtime-playground/dist", "packages/runtime-playground/package.json", "packages/cli/dist", @@ -88,8 +94,8 @@ "LICENSE" ], "scripts": { - "build": "node ./node_modules/typescript/bin/tsc -b packages/runtime-core packages/runtime-playground packages/cli && node scripts/ensure-cli-bin-executable.mjs && tsx scripts/write-cli-build-provenance.ts", - "build:release": "node ./node_modules/typescript/bin/tsc -b --force packages/runtime-core packages/runtime-playground packages/cli && node scripts/ensure-cli-bin-executable.mjs && tsx scripts/write-cli-build-provenance.ts", + "build": "node ./node_modules/typescript/bin/tsc -b --force packages/runtime-core packages/runtime-playground packages/runtime-native packages/cli && node scripts/ensure-cli-bin-executable.mjs && tsx scripts/write-cli-build-provenance.ts", + "build:release": "node ./node_modules/typescript/bin/tsc -b --force packages/runtime-core packages/runtime-playground packages/runtime-native packages/cli && node scripts/ensure-cli-bin-executable.mjs && tsx scripts/write-cli-build-provenance.ts", "cloudflare:build": "npm --prefix packages/runtime-cloudflare run build", "cloudflare:check": "npm --prefix packages/runtime-cloudflare run check", "cloudflare:package-dry-run": "npm --prefix packages/runtime-cloudflare run package:dry-run", @@ -156,6 +162,7 @@ "test:runtime-command-artifact-bounds": "tsx tests/runtime-command-artifact-bounds.test.ts", "test:native-agent-task-interruption": "node tests/execute-native-agent-task-interruption.test.mjs", "test:native-agent-task-playground-e2e": "tsx tests/execute-native-agent-task-playground-e2e.test.ts", + "test:native-runtime": "tsx tests/native-runtime.test.ts && tsx scripts/backend-package-adapter-registry-smoke.ts", "test:bench-command-step-behavior": "tsx tests/bench-command-step-behavior.test.ts", "test:external-http-load-integration": "npm run build && tsx tests/external-http-load.integration.test.ts", "test:generic-primitives": "npm run test:artifact-path-primitives && npm run test:browser-callback-materialization-contracts && npm run test:source-package-compiler-primitives && npm run test:bench-command-step-behavior && npm run test:generic-ability-runtime-run", @@ -181,6 +188,7 @@ "workspaces": [ "packages/cli", "packages/runtime-core", + "packages/runtime-native", "packages/runtime-playground", "packages/wordpress-plugin" ], @@ -198,6 +206,7 @@ "dependencies": { "@automattic/wp-codebox-cli": "file:packages/cli", "@automattic/wp-codebox-core": "file:packages/runtime-core", + "@automattic/wp-codebox-native": "file:packages/runtime-native", "@automattic/wp-codebox-playground": "file:packages/runtime-playground", "@php-wasm/node-8-3": "file:runtime-overlays/php-wasm-node-8-3", "@php-wasm/node-8-4": "3.1.46", diff --git a/packages/cli/package.json b/packages/cli/package.json index b308da5ae..0bedb6475 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", + "@automattic/wp-codebox-native": "file:../runtime-native", "@automattic/wp-codebox-playground": "file:../runtime-playground" } } diff --git a/packages/cli/src/recipe-backend-package.ts b/packages/cli/src/recipe-backend-package.ts index 040b1f833..8647b1edb 100644 --- a/packages/cli/src/recipe-backend-package.ts +++ b/packages/cli/src/recipe-backend-package.ts @@ -3,6 +3,7 @@ import { readFile, stat } from "node:fs/promises" import { basename, dirname, join, resolve } from "node:path" import { pathToFileURL } from "node:url" import { normalizeRuntimeBackendKind, type RuntimeBackendFactoryContext, type RuntimeBackendKind, type WorkspaceRecipe, type WorkspaceRecipeRuntimeBackendPackage } from "@automattic/wp-codebox-core" +import type { NativeRuntimeDriverFactory } from "@automattic/wp-codebox-native" export interface RuntimeBackendPackageProvenance { schema: "wp-codebox/runtime-backend-package/v1" @@ -94,7 +95,25 @@ const playgroundRuntimeBackendPackageAdapter: RuntimeBackendPackageAdapter = { }, } -const runtimeBackendPackageAdapterRegistry = new RuntimeBackendPackageAdapterRegistry([playgroundRuntimeBackendPackageAdapter]) +const nativeRuntimeBackendPackageAdapter: RuntimeBackendPackageAdapter = { + backendKind: "wordpress-native", + prepare(loadedPackage) { + const { backendPackage, entrypoint, module } = loadedPackage + if (backendPackage.kind !== "native") { + throw backendPackageError(backendPackage, `Unsupported native WordPress runtime backend package kind: ${backendPackage.kind}`) + } + if (!isNativeRuntimeDriverFactory(module)) { + throw backendPackageError(backendPackage, `Runtime backend package entrypoint must export createNativeRuntimeDriver(): ${entrypoint}`) + } + + return { + runtimeBackendContext: { nativeRuntimeDriver: (module as NativeRuntimeDriverFactory).createNativeRuntimeDriver() }, + diagnostics: [{ status: "passed", message: "Entrypoint exports createNativeRuntimeDriver" }], + } + }, +} + +const runtimeBackendPackageAdapterRegistry = new RuntimeBackendPackageAdapterRegistry([playgroundRuntimeBackendPackageAdapter, nativeRuntimeBackendPackageAdapter]) export class RecipeRuntimeBackendPackageError extends Error { readonly code = "recipe-runtime-backend-package-invalid" @@ -223,6 +242,10 @@ function isPlaygroundCliModule(value: unknown): value is RuntimeCliEntrypointMod return Boolean(value && typeof value === "object" && "runCLI" in value && typeof (value as { runCLI?: unknown }).runCLI === "function") } +function isNativeRuntimeDriverFactory(value: unknown): value is NativeRuntimeDriverFactory { + return Boolean(value && typeof value === "object" && "createNativeRuntimeDriver" in value && typeof (value as { createNativeRuntimeDriver?: unknown }).createNativeRuntimeDriver === "function") +} + function backendPackageError(backendPackage: WorkspaceRecipeRuntimeBackendPackage, message: string): RecipeRuntimeBackendPackageError { return new RecipeRuntimeBackendPackageError(message, backendPackage, [{ status: "failed", message }]) } diff --git a/packages/cli/src/runtime-backends.ts b/packages/cli/src/runtime-backends.ts index e05912d55..335e894ae 100644 --- a/packages/cli/src/runtime-backends.ts +++ b/packages/cli/src/runtime-backends.ts @@ -1,8 +1,9 @@ import { createRuntimeBackendRegistry, runtimeBackendRecipeAliases, type RuntimeBackend, type RuntimeBackendFactoryContext, type RuntimeBackendKind, type RuntimeBackendRecipePolicy } from "@automattic/wp-codebox-core" import type { CommandDefinition } from "@automattic/wp-codebox-core/contracts" import { playgroundRuntimeBackendProvider } from "@automattic/wp-codebox-playground" +import { nativeRuntimeBackendProvider } from "@automattic/wp-codebox-native" -const cliRuntimeBackendRegistry = createRuntimeBackendRegistry([playgroundRuntimeBackendProvider]) +const cliRuntimeBackendRegistry = createRuntimeBackendRegistry([playgroundRuntimeBackendProvider, nativeRuntimeBackendProvider]) export function listCliRuntimeBackendKinds(): RuntimeBackendKind[] { return cliRuntimeBackendRegistry.list().flatMap((kind) => [kind, ...runtimeBackendRecipeAliases(kind)]) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index c9d2c6145..c02e98858 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -7,6 +7,7 @@ }, "references": [ { "path": "../runtime-core" }, + { "path": "../runtime-native" }, { "path": "../runtime-playground" } ], "include": ["src/**/*.ts"] diff --git a/packages/runtime-core/src/runtime-backend-resolver.ts b/packages/runtime-core/src/runtime-backend-resolver.ts index cca1df39b..7d8adc720 100644 --- a/packages/runtime-core/src/runtime-backend-resolver.ts +++ b/packages/runtime-core/src/runtime-backend-resolver.ts @@ -12,6 +12,8 @@ export const WORDPRESS_RUNTIME_BACKEND_ALIAS = "wordpress" as const */ export interface RuntimeBackendFactoryContext { readonly cliModule?: unknown + /** Adapter-owned process/container driver; core keeps this implementation-neutral. */ + readonly nativeRuntimeDriver?: unknown } export interface RuntimeBackendRecipePolicy { diff --git a/packages/runtime-native/package.json b/packages/runtime-native/package.json new file mode 100644 index 000000000..d2d192cfc --- /dev/null +++ b/packages/runtime-native/package.json @@ -0,0 +1,17 @@ +{ + "name": "@automattic/wp-codebox-native", + "version": "0.26.8", + "description": "Contained native PHP runtime adapter for WP Codebox.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist"], + "scripts": { "build": "tsc -b" }, + "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core" } +} diff --git a/packages/runtime-native/src/index.ts b/packages/runtime-native/src/index.ts new file mode 100644 index 000000000..a33d76121 --- /dev/null +++ b/packages/runtime-native/src/index.ts @@ -0,0 +1,2 @@ +/** Contained native PHP runtime adapter. */ +export { NativeRuntimeBackend, NativeRuntimeUnavailableError, createNativeRuntimeBackend, nativeRuntimeBackendProvider, type NativeRuntimeBackendOptions, type NativeRuntimeDriver, type NativeRuntimeDriverFactory, type NativeRuntimeProvenance, type NativeRuntimeProvenanceEvidence } from "./native-runtime.js" diff --git a/packages/runtime-native/src/native-runtime.ts b/packages/runtime-native/src/native-runtime.ts new file mode 100644 index 000000000..4ffe08912 --- /dev/null +++ b/packages/runtime-native/src/native-runtime.ts @@ -0,0 +1,138 @@ +import { randomUUID } from "node:crypto" +import { assertRuntimeCommandAllowed, assertRuntimeSecretEnvTargetsAvailable, type ArtifactBundle, type ArtifactSpec, type ExecutionResult, type ExecutionSpec, type MountSpec, type ObservationResult, type ObservationSpec, type Runtime, type RuntimeBackend, type RuntimeBackendFactoryContext, type RuntimeBackendProvider, type RuntimeCreateSpec, type RuntimeInfo, type Snapshot } from "@automattic/wp-codebox-core" + +export interface NativeRuntimeProvenance { + schema: "wp-codebox/native-runtime-provenance/v1" + backend: "wordpress-native" + php: { version: string; sapi: string } + container: { image: string; digest: string; containment: "required" } + opcache: { enabled: true; persistent: true; evidence: Record } + httpConcurrency: { workers: number; model: string } + database: { integration: "managed-runtime-service"; disposable: true } + browser: { authentication: "fixture-only"; credentials: "runtime-generated" } + benchmarks: { coldStartup: true; warmNoopPhp: true; dynamicWordPressRequest: true } + representative: { scope: "local"; productionRum: false } +} + +export interface NativeRuntimeProvenanceEvidence { + path: string + sha256: string +} + +/** Process/container details stay in this adapter; host PHP is never a fallback. */ +export interface NativeRuntimeDriver { + create(spec: RuntimeCreateSpec): Promise + /** Persist reviewer-safe provenance before the runtime can accept commands. */ + recordProvenance(provenance: NativeRuntimeProvenance): Promise + info?(): Promise> + mount?(spec: MountSpec): Promise + execute(spec: ExecutionSpec): Promise + observe(spec: ObservationSpec): Promise + snapshot(options?: unknown): Promise + collectArtifacts(spec?: ArtifactSpec): Promise + destroy(): Promise +} + +export interface NativeRuntimeDriverFactory { + createNativeRuntimeDriver(): NativeRuntimeDriver +} + +export interface NativeRuntimeBackendOptions { driver?: NativeRuntimeDriver } + +export class NativeRuntimeUnavailableError extends Error { + constructor() { + super("wordpress-native requires a contained native runtime driver; host PHP, ambient credentials, and production state are never used as fallbacks.") + this.name = "NativeRuntimeUnavailableError" + } +} + +export class NativeRuntimeBackend implements RuntimeBackend { + readonly kind = "wordpress-native" as const + constructor(private readonly options: NativeRuntimeBackendOptions = {}) {} + async create(spec: RuntimeCreateSpec): Promise { + if (!this.options.driver) throw new NativeRuntimeUnavailableError() + try { + const provenance = await this.options.driver.create(spec) + assertNativeProvenance(provenance) + assertNativeProvenanceEvidence(await this.options.driver.recordProvenance(provenance)) + return new NativeRuntime(spec, this.options.driver) + } catch (error) { + await this.options.driver.destroy().catch(() => undefined) + throw error + } + } +} + +class NativeRuntime implements Runtime { + private readonly id = `native-${randomUUID()}` + private readonly createdAt = new Date().toISOString() + private destroyed = false + private destroying = false + private destroyPromise?: Promise + constructor(private readonly spec: RuntimeCreateSpec, private readonly driver: NativeRuntimeDriver) {} + async info(): Promise { + const driverInfo = await this.driver.info?.() + return { id: driverInfo?.id ?? this.id, backend: "wordpress-native", environment: this.spec.environment, createdAt: driverInfo?.createdAt ?? this.createdAt, status: this.destroyed ? "destroyed" : "created", ...(driverInfo?.previewUrl ? { previewUrl: driverInfo.previewUrl } : {}) } + } + async mount(spec: MountSpec): Promise { + this.assertLive() + if (!this.driver.mount) throw new Error("wordpress-native driver does not support mounts") + await this.driver.mount(spec) + } + async execute(spec: ExecutionSpec): Promise { + this.assertLive() + assertRuntimeCommandAllowed(spec.command, this.spec.policy) + assertRuntimeSecretEnvTargetsAvailable(this.spec.secretEnvTargets, spec.environment ?? {}) + assertAuthenticatedBrowserAction(spec) + return await this.driver.execute(spec) + } + async observe(spec: ObservationSpec): Promise { this.assertLive(); return await this.driver.observe(spec) } + async snapshot(options?: unknown): Promise { this.assertLive(); return await this.driver.snapshot(options) } + async collectArtifacts(spec?: ArtifactSpec): Promise { return await this.driver.collectArtifacts(spec) } + async destroy(): Promise { + if (!this.destroyPromise) { + this.destroying = true + this.destroyPromise = this.driver.destroy().finally(() => { + this.destroyed = true + this.destroying = false + }) + } + await this.destroyPromise + } + private assertLive(): void { + if (this.destroyed || this.destroying) throw new Error("Cannot use a destroyed native runtime") + } +} + +export function createNativeRuntimeBackend(options: NativeRuntimeBackendOptions = {}): RuntimeBackend { return new NativeRuntimeBackend(options) } + +export const nativeRuntimeBackendProvider: RuntimeBackendProvider = { + kind: "wordpress-native", + createBackend(context: RuntimeBackendFactoryContext = {}) { + return createNativeRuntimeBackend({ driver: context.nativeRuntimeDriver as NativeRuntimeDriver | undefined }) + }, +} + +function assertNativeProvenance(provenance: NativeRuntimeProvenance): void { + if (provenance.schema !== "wp-codebox/native-runtime-provenance/v1" || provenance.backend !== "wordpress-native" || provenance.container.containment !== "required") throw new Error("wordpress-native driver did not prove contained execution") + if (!provenance.php.version || !provenance.php.sapi || !provenance.container.image || !/^sha256:[a-f0-9]{64}$/i.test(provenance.container.digest) || !provenance.opcache.enabled || !provenance.opcache.persistent || Object.keys(provenance.opcache.evidence).length === 0) throw new Error("wordpress-native driver did not provide pinned native PHP and persistent OPcache evidence") + if (!Number.isInteger(provenance.httpConcurrency.workers) || provenance.httpConcurrency.workers < 2 || !provenance.httpConcurrency.model) throw new Error("wordpress-native driver did not provide a concurrent HTTP worker model") + if (provenance.database.integration !== "managed-runtime-service" || !provenance.database.disposable) throw new Error("wordpress-native driver did not prove disposable managed database integration") + if (provenance.browser.authentication !== "fixture-only" || provenance.browser.credentials !== "runtime-generated") throw new Error("wordpress-native driver did not prove fixture-only browser authentication") + if (!provenance.benchmarks.coldStartup || !provenance.benchmarks.warmNoopPhp || !provenance.benchmarks.dynamicWordPressRequest) throw new Error("wordpress-native driver did not provide cold startup, warm PHP, and dynamic WordPress benchmark coverage") + if (provenance.representative.scope !== "local" || provenance.representative.productionRum !== false) throw new Error("wordpress-native driver must identify results as local representative evidence") +} + +function assertNativeProvenanceEvidence(evidence: NativeRuntimeProvenanceEvidence): void { + if (!evidence.path || !/^[a-f0-9]{64}$/i.test(evidence.sha256)) { + throw new Error("wordpress-native driver did not persist native runtime provenance evidence") + } +} + +function assertAuthenticatedBrowserAction(spec: ExecutionSpec): void { + if (spec.command !== "wordpress.browser-actions") return + const auth = (spec.args ?? []).find((arg) => arg.startsWith("auth="))?.slice("auth=".length) + if (auth !== "wordpress-admin" && auth !== "storage-state") { + throw new Error("wordpress-native browser actions require runtime fixture authentication") + } +} diff --git a/packages/runtime-native/tsconfig.json b/packages/runtime-native/tsconfig.json new file mode 100644 index 000000000..da62fca89 --- /dev/null +++ b/packages/runtime-native/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "composite": true, "rootDir": "src", "outDir": "dist" }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../runtime-core" }] +} diff --git a/scripts/backend-package-adapter-registry-smoke.ts b/scripts/backend-package-adapter-registry-smoke.ts index 0065342b0..4eccbfaaa 100644 --- a/scripts/backend-package-adapter-registry-smoke.ts +++ b/scripts/backend-package-adapter-registry-smoke.ts @@ -26,11 +26,15 @@ async function main(): Promise { const root = await mkdtemp(join(tmpdir(), "wp-codebox-backend-package-")) try { const packageDirectory = join(root, "playground-backend") + const nativePackageDirectory = join(root, "native-backend") await writeFile(join(root, "package.json"), JSON.stringify({ type: "module" })) await writeFile(join(root, "invalid.js"), "export const notRunCLI = true\n") await mkdir(packageDirectory) await writeFile(join(packageDirectory, "package.json"), JSON.stringify({ name: "local-playground-backend", version: "1.2.3", type: "module", exports: "./index.js" })) await writeFile(join(packageDirectory, "index.js"), "export async function runCLI() { return { php: { requestHandler: {} } } }\n") + await mkdir(nativePackageDirectory) + await writeFile(join(nativePackageDirectory, "package.json"), JSON.stringify({ name: "local-native-backend", version: "1.2.3", type: "module", exports: "./index.js" })) + await writeFile(join(nativePackageDirectory, "index.js"), "export function createNativeRuntimeDriver() { return {} }\n") const genericSchemaResult = validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", @@ -53,11 +57,24 @@ async function main(): Promise { assert(typeof (prepared.runtimeBackendContext.cliModule as { runCLI?: unknown }).runCLI === "function", "Expected Playground adapter to expose cliModule.runCLI") assert(prepared.provenance.diagnostics.some((diagnostic) => diagnostic.message === "Entrypoint exports runCLI"), "Expected Playground runCLI diagnostic") + const nativePrepared = await prepareRecipeRuntimeBackendPackage({ + schema: "wp-codebox/workspace-recipe/v1", + runtime: { backend: "wordpress-native", backendPackage: { kind: "native", source: "./native-backend", package: "local-native-backend" } }, + workflow: { steps: [{ command: "wordpress.run-php" }] }, + }, root, "wordpress-native") + assert(nativePrepared?.runtimeBackendContext.nativeRuntimeDriver !== undefined, "Expected native adapter to expose a runtime driver") + assert(nativePrepared?.provenance.diagnostics.some((diagnostic) => diagnostic.message === "Entrypoint exports createNativeRuntimeDriver"), "Expected native driver factory diagnostic") + await assertRejects(() => prepareRecipeRuntimeBackendPackage({ schema: "wp-codebox/workspace-recipe/v1", runtime: { backendPackage: { kind: "playground", source: "./invalid.js" } }, }, root, "wordpress-playground"), "must export runCLI") + await assertRejects(() => prepareRecipeRuntimeBackendPackage({ + schema: "wp-codebox/workspace-recipe/v1", + runtime: { backendPackage: { kind: "native", source: "./invalid.js" } }, + }, root, "wordpress-native"), "must export createNativeRuntimeDriver") + await assertRejects(() => prepareRecipeRuntimeBackendPackage({ schema: "wp-codebox/workspace-recipe/v1", runtime: { backendPackage: { kind: "future", source: "./invalid.js" } }, diff --git a/scripts/package-release-artifact.ts b/scripts/package-release-artifact.ts index d3327c1e0..d4ed696ee 100644 --- a/scripts/package-release-artifact.ts +++ b/scripts/package-release-artifact.ts @@ -37,7 +37,7 @@ try { await mkdir(join(packageRoot, "scripts"), { recursive: true }) await cp(resolve(repoRoot, "scripts", "apply-development-patches.mjs"), join(packageRoot, "scripts", "apply-development-patches.mjs")) - for (const packageName of ["runtime-core", "runtime-playground", "cli"]) { + for (const packageName of ["runtime-core", "runtime-native", "runtime-playground", "cli"]) { const sourceRoot = resolve(repoRoot, "packages", packageName) const targetRoot = join(packageRoot, "packages", packageName) await mkdir(targetRoot, { recursive: true }) @@ -141,6 +141,7 @@ async function materializeWorkspacePackages(root: string): Promise { const packages = new Map([ ["runtime-core", "wp-codebox-core"], + ["runtime-native", "wp-codebox-native"], ["runtime-playground", "wp-codebox-playground"], ["cli", "wp-codebox-cli"], ]) diff --git a/scripts/runtime-backend-registry-smoke.ts b/scripts/runtime-backend-registry-smoke.ts index 644b8f12e..9f2b49423 100644 --- a/scripts/runtime-backend-registry-smoke.ts +++ b/scripts/runtime-backend-registry-smoke.ts @@ -44,21 +44,22 @@ assert.throws( /Unsupported runtime backend: missing-backend; known runtime backends: example-backend/, ) -assert.deepEqual(listCliRuntimeBackendKinds(), ["wordpress-playground", "wordpress"]) +assert.deepEqual(listCliRuntimeBackendKinds(), ["wordpress-playground", "wordpress", "wordpress-native"]) assert.equal(resolveCliRuntimeBackend("wordpress-playground").kind, "wordpress-playground") assert.equal(resolveCliRuntimeBackend("wordpress").kind, "wordpress-playground") +assert.equal(resolveCliRuntimeBackend("wordpress-native").kind, "wordpress-native") assert.equal(listCliRecipeCommandDefinitions().some((command) => command.id === "wordpress.run-php"), true) assert.deepEqual(cliRuntimeBackendRecipePolicy().runtimeOverlayLibraries, ["php-ai-client"]) assert.throws( () => resolveCliRuntimeBackend("missing-backend"), - /Unsupported runtime backend: missing-backend; known runtime backends: wordpress-playground/, + /Unsupported runtime backend: missing-backend; known runtime backends: wordpress-playground, wordpress-native/, ) const openSchema = createWorkspaceRecipeJsonSchema() assert.deepEqual((openSchema as any).properties.runtime.properties.backend, { type: "string" }) const cliSchema = createWorkspaceRecipeJsonSchema({ runtimeBackendKinds: listCliRuntimeBackendKinds() }) -assert.deepEqual((cliSchema as any).properties.runtime.properties.backend, { enum: ["wordpress-playground", "wordpress"] }) +assert.deepEqual((cliSchema as any).properties.runtime.properties.backend, { enum: ["wordpress-playground", "wordpress", "wordpress-native"] }) const cliPolicy = cliRuntimeBackendRecipePolicy() const cliProviderSchema = createWorkspaceRecipeJsonSchema({ diff --git a/tests/native-runtime.test.ts b/tests/native-runtime.test.ts new file mode 100644 index 000000000..82995a379 --- /dev/null +++ b/tests/native-runtime.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict" +import { createRuntime, type ArtifactBundle, type ExecutionSpec, type RuntimeCreateSpec } from "@automattic/wp-codebox-core" +import { createNativeRuntimeBackend, type NativeRuntimeDriver, type NativeRuntimeProvenance } from "@automattic/wp-codebox-native" + +const provenance: NativeRuntimeProvenance = { + schema: "wp-codebox/native-runtime-provenance/v1", + backend: "wordpress-native", + php: { version: "8.4.1", sapi: "fpm-fcgi" }, + container: { image: "example.test/php", digest: `sha256:${"a".repeat(64)}`, containment: "required" }, + opcache: { enabled: true, persistent: true, evidence: { status: { opcache_enabled: true } } }, + httpConcurrency: { workers: 2, model: "php-fpm" }, + database: { integration: "managed-runtime-service", disposable: true }, + browser: { authentication: "fixture-only", credentials: "runtime-generated" }, + benchmarks: { coldStartup: true, warmNoopPhp: true, dynamicWordPressRequest: true }, + representative: { scope: "local", productionRum: false }, +} + +const spec: RuntimeCreateSpec = { + backend: "wordpress-native", + environment: { kind: "wordpress", name: "WordPress", version: "6.8", phpVersion: "8.4" }, + policy: { network: "deny", filesystem: "sandbox", commands: ["wordpress.run-php", "wordpress.browser-actions", "wordpress.bench"], secrets: "none", approvals: "never" }, +} + +function driver(calls: string[]): NativeRuntimeDriver { + return { + async create() { calls.push("create"); return provenance }, + async recordProvenance(value) { + calls.push(`provenance:${value.php.version}`) + return { path: "runtime/native-runtime-provenance.json", sha256: "b".repeat(64) } + }, + async mount() { calls.push("mount") }, + async execute(value: ExecutionSpec) { + calls.push(`execute:${value.command}`) + return { id: "command", command: value.command, args: value.args ?? [], exitCode: 0, stdout: "ok", stderr: "", startedAt: "2026-09-03T00:00:00.000Z", finishedAt: "2026-09-03T00:00:00.001Z" } + }, + async observe() { calls.push("observe"); return { type: "browser-result", data: { authenticated: true }, observedAt: "2026-09-03T00:00:00.000Z" } }, + async snapshot() { calls.push("snapshot"); return { id: "snapshot", createdAt: "2026-09-03T00:00:00.000Z", metadata: {} } }, + async collectArtifacts() { calls.push("artifacts"); return {} as ArtifactBundle }, + async destroy() { calls.push("destroy") }, + } +} + +const calls: string[] = [] +const runtime = await createRuntime(spec, createNativeRuntimeBackend({ driver: driver(calls) })) +assert.deepEqual(calls, ["create", "provenance:8.4.1"]) +assert.equal((await runtime.info()).backend, "wordpress-native") +await runtime.mount({ type: "directory", source: "/fixture", target: "/var/www/html/wp-content/plugins/fixture", mode: "readonly" }) +await runtime.execute({ command: "wordpress.run-php", args: ["code= runtime.execute({ command: "wordpress.wp-cli" }), /not allowed/) +await assert.rejects(() => runtime.execute({ command: "wordpress.browser-actions" }), /require runtime fixture authentication/) +await Promise.all([runtime.destroy(), runtime.destroy()]) +assert.equal(calls.filter((call) => call === "destroy").length, 1) +assert.equal((await runtime.info()).status, "destroyed") +await assert.rejects(() => runtime.execute({ command: "wordpress.run-php" }), /destroyed/) + +const invalidCalls: string[] = [] +const invalidDriver = driver(invalidCalls) +invalidDriver.create = async () => ({ ...provenance, httpConcurrency: { workers: 1, model: "php-fpm" } }) +await assert.rejects(() => createRuntime(spec, createNativeRuntimeBackend({ driver: invalidDriver })), /concurrent HTTP worker model/) +assert.deepEqual(invalidCalls, ["destroy"]) + +const incompleteBenchmarkCalls: string[] = [] +const incompleteBenchmarkDriver = driver(incompleteBenchmarkCalls) +incompleteBenchmarkDriver.create = async () => ({ ...provenance, benchmarks: { ...provenance.benchmarks, warmNoopPhp: false } }) +await assert.rejects(() => createRuntime(spec, createNativeRuntimeBackend({ driver: incompleteBenchmarkDriver })), /benchmark coverage/) +assert.deepEqual(incompleteBenchmarkCalls, ["destroy"]) + +console.log("native runtime adapter ok") diff --git a/tests/public-api-contract.test.ts b/tests/public-api-contract.test.ts index 6e0a3b8ca..58049153a 100644 --- a/tests/public-api-contract.test.ts +++ b/tests/public-api-contract.test.ts @@ -104,6 +104,7 @@ assert.deepEqual(exportKeys(rootPackage), [ "./runtime-presets", "./playground", "./playground/public", + "./native", "./cli", "./cli/recipe-secret-env", "./cli/bounded-recipe-plan", diff --git a/tests/root-package-boundary.test.mjs b/tests/root-package-boundary.test.mjs index cfdbc6749..94d5c1649 100644 --- a/tests/root-package-boundary.test.mjs +++ b/tests/root-package-boundary.test.mjs @@ -9,7 +9,7 @@ const root = resolve(import.meta.dirname, "..") const rootPackage = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")) const rootLock = JSON.parse(await readFile(resolve(root, "npm-shrinkwrap.json"), "utf8")) -assert.deepEqual(rootPackage.workspaces, ["packages/cli", "packages/runtime-core", "packages/runtime-playground", "packages/wordpress-plugin"]) +assert.deepEqual(rootPackage.workspaces, ["packages/cli", "packages/runtime-core", "packages/runtime-native", "packages/runtime-playground", "packages/wordpress-plugin"]) assert.equal(rootPackage.workspaces.includes("packages/runtime-cloudflare"), false, "runtime-cloudflare must not join the default install lane") assert.equal(rootLock.packages?.["packages/runtime-cloudflare"], undefined, "the root shrinkwrap must not retain Cloudflare package metadata") for (const dependency of ["@cloudflare/workers-types", "wrangler"]) { diff --git a/tsconfig.json b/tsconfig.json index 9f6643069..e45ece425 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "references": [ { "path": "./packages/runtime-core" }, { "path": "./packages/runtime-playground" }, + { "path": "./packages/runtime-native" }, { "path": "./packages/cli" } ] } From 9f908a8db688c41cc77f049e63954b00775aa3e1 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 14:19:17 -0400 Subject: [PATCH 2/9] fix(runtime): provide contained native PHP driver --- docs/native-php-runtime.md | 24 ++- package.json | 1 + .../src/docker-native-runtime.ts | 179 ++++++++++++++++++ packages/runtime-native/src/index.ts | 1 + packages/runtime-native/src/native-runtime.ts | 11 +- .../native-docker-runtime.integration.test.ts | 25 +++ tests/native-runtime.test.ts | 28 ++- 7 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 packages/runtime-native/src/docker-native-runtime.ts create mode 100644 tests/native-docker-runtime.integration.test.ts diff --git a/docs/native-php-runtime.md b/docs/native-php-runtime.md index 8df70ec94..a03e8b09f 100644 --- a/docs/native-php-runtime.md +++ b/docs/native-php-runtime.md @@ -4,9 +4,25 @@ backend-neutral recipe command IDs. The adapter never substitutes host PHP, ambient credentials, or production state when a contained driver is unavailable. -## Driver Contract +## Built-in Driver -The CLI loads an adapter-owned contained driver from `runtime.backendPackage` when +`wordpress-native` now uses the built-in Docker driver when no `backendPackage` is +declared. It creates an isolated internal Docker network, a disposable MariaDB +sidecar on tmpfs, and a digest-pinned WordPress/PHP Apache container. Apache prefork +is configured with two persistent workers and PHP OPcache has timestamp validation +configuration. Docker is a hard requirement: the adapter fails closed rather than +using host PHP or host credentials. + +The driver creates a random fixture-only database/admin secret for every run. It +never imports a host browser profile. `wordpress.browser-actions` captures a +machine-readable local network record, and `wordpress.bench` emits cold startup, +warm no-op PHP, and dynamic HTTP timings marked as local evidence, not production +RUM. The native artifact bundle contains `files/native/browser-network.json` and +`files/native/commands.json` alongside `native-runtime-provenance.json`. + +## Optional Driver Contract + +The CLI can load an adapter-owned contained driver from `runtime.backendPackage` when the backend is `wordpress-native`. Its package uses `kind: "native"` and exports `createNativeRuntimeDriver()`. The driver must report a digest-pinned container image, PHP version and SAPI, persistent enabled OPcache evidence, at least two HTTP @@ -42,7 +58,7 @@ startup crosses a worker/process boundary, warm no-op PHP retains the shared OPcache, and the dynamic request exercises WordPress routing. These are local representative measurements, not production RUM. -## Example selection +## Example optional selection ```json { @@ -56,7 +72,7 @@ representative measurements, not production RUM. } ``` -The native package is responsible for translating the existing backend-neutral +The optional native package is responsible for translating the existing backend-neutral `wordpress.*` commands, including browser actions and fixture authentication, into its contained runtime. It must not read host PHP configuration, browser profiles, ambient credentials, or production state. diff --git a/package.json b/package.json index 227b6a9f6..e8d8b022d 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,7 @@ "test:native-agent-task-interruption": "node tests/execute-native-agent-task-interruption.test.mjs", "test:native-agent-task-playground-e2e": "tsx tests/execute-native-agent-task-playground-e2e.test.ts", "test:native-runtime": "tsx tests/native-runtime.test.ts && tsx scripts/backend-package-adapter-registry-smoke.ts", + "test:native-docker-runtime": "npm run build && tsx tests/native-docker-runtime.integration.test.ts", "test:bench-command-step-behavior": "tsx tests/bench-command-step-behavior.test.ts", "test:external-http-load-integration": "npm run build && tsx tests/external-http-load.integration.test.ts", "test:generic-primitives": "npm run test:artifact-path-primitives && npm run test:browser-callback-materialization-contracts && npm run test:source-package-compiler-primitives && npm run test:bench-command-step-behavior && npm run test:generic-ability-runtime-run", diff --git a/packages/runtime-native/src/docker-native-runtime.ts b/packages/runtime-native/src/docker-native-runtime.ts new file mode 100644 index 000000000..5c9bfeba2 --- /dev/null +++ b/packages/runtime-native/src/docker-native-runtime.ts @@ -0,0 +1,179 @@ +import { spawn } from "node:child_process" +import { createHash, randomBytes, randomUUID } from "node:crypto" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { ArtifactBundleWriter, type ArtifactBundle, type ArtifactSpec, type ExecutionResult, type ExecutionSpec, type MountSpec, type ObservationResult, type ObservationSpec, type RuntimeCreateSpec, type RuntimeInfo, type Snapshot, resolveRuntimeSecretEnvTargets } from "@automattic/wp-codebox-core" +import type { NativeRuntimeDriver, NativeRuntimeProvenance, NativeRuntimeProvenanceEvidence } from "./native-runtime.js" + +// These immutable references make an accidentally retagged image unable to change a run. +const WORDPRESS_IMAGE = "wordpress@sha256:cc3f3ee1388660fa5bf8158f2267b5126d0c6cd2b98c55e5cbc75182b2e28b84" +const MARIADB_IMAGE = "mariadb@sha256:3cf072264d4e8537099cdf309363cf3a1cb5ee678fd573e46eca1322e9c37095" + +export interface DockerNativeRuntimeDependencies { + run(command: string, args: string[], options?: { input?: string; timeoutMs?: number }): Promise<{ stdout: string; stderr: string }> + fetch(url: string, options?: { headers?: Record }): Promise<{ status: number; body: string }> + temporaryDirectory(): string +} + +export class NativeContainmentUnavailableError extends Error { + constructor(message = "wordpress-native requires trusted Docker containment tools; no host PHP fallback is used.") { + super(message) + this.name = "NativeContainmentUnavailableError" + } +} + +export function createDockerNativeRuntimeDriver(dependencies: DockerNativeRuntimeDependencies = nodeDependencies()): NativeRuntimeDriver { + return new DockerNativeRuntimeDriver(dependencies) +} + +class DockerNativeRuntimeDriver implements NativeRuntimeDriver { + private readonly id = `wp-codebox-native-${randomUUID()}` + private readonly network = `${this.id}-network` + private readonly database = `${this.id}-db` + private readonly app = `${this.id}-app` + private readonly root: string + private spec?: RuntimeCreateSpec + private previewUrl?: string + private provenance?: NativeRuntimeProvenance + private destroyed = false + private destroyPromise?: Promise + private readonly commands: Array> = [] + private readonly browserNetwork: Array> = [] + private readonly fixturePassword = randomBytes(24).toString("base64url") + + constructor(private readonly dependencies: DockerNativeRuntimeDependencies) { + this.root = join(dependencies.temporaryDirectory(), this.id) + } + + async create(spec: RuntimeCreateSpec): Promise { + this.spec = spec + await this.runDocker(["version", "--format", "{{.Server.Version}}"]) + await mkdir(this.root, { recursive: true, mode: 0o700 }) + const environment = { ...(spec.runtimeEnv ?? {}), ...resolveRuntimeSecretEnvTargets(spec.secretEnv ?? {}, spec.secretEnvTargets) } + await this.runDocker(["network", "create", "--internal", this.network]) + await this.runDocker(["run", "-d", "--name", this.database, "--network", this.network, "--tmpfs", "/var/lib/mysql:rw,noexec,nosuid,size=256m", "-e", `MARIADB_ROOT_PASSWORD=${this.fixturePassword}`, "-e", "MARIADB_DATABASE=wordpress", "-e", "MARIADB_USER=wordpress", "-e", `MARIADB_PASSWORD=${this.fixturePassword}`, MARIADB_IMAGE]) + const appArgs = ["run", "-d", "--name", this.app, "--network", this.network, "-p", "127.0.0.1::80", "-e", "WORDPRESS_DB_HOST=" + this.database, "-e", "WORDPRESS_DB_NAME=wordpress", "-e", "WORDPRESS_DB_USER=wordpress", "-e", `WORDPRESS_DB_PASSWORD=${this.fixturePassword}`, "-e", "WORDPRESS_CONFIG_EXTRA=define('WP_CODEBOX_FIXTURE_AUTH', true);", "-e", "PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d", "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m"] + for (const [name, value] of Object.entries(environment)) appArgs.push("-e", `${name}=${value}`) + // Apache's prefork MPM has two permanent child workers; OPcache stays in each worker. + appArgs.push(WORDPRESS_IMAGE, "bash", "-lc", "printf '%s\\n' 'opcache.enable=1' 'opcache.enable_cli=1' 'opcache.validate_timestamps=0' 'StartServers 2' 'MinSpareServers 2' 'MaxSpareServers 2' > /usr/local/etc/php/conf.d/zz-codebox-opcache.ini && sed -i 's/StartServers .*/StartServers 2/; s/MinSpareServers .*/MinSpareServers 2/; s/MaxSpareServers .*/MaxSpareServers 2/' /etc/apache2/mods-enabled/mpm_prefork.conf && docker-entrypoint.sh apache2-foreground") + await this.runDocker(appArgs) + const port = (await this.runDocker(["port", this.app, "80/tcp"])).stdout.trim().match(/:(\d+)$/)?.[1] + if (!port) throw new NativeContainmentUnavailableError("Docker did not publish the contained WordPress HTTP port.") + this.previewUrl = `http://127.0.0.1:${port}` + await this.waitForHttp() + const php = (await this.runDocker(["exec", this.app, "php", "-r", "echo PHP_VERSION, '\\n', PHP_SAPI;"])).stdout.trim().split("\n") + const opcache = JSON.parse((await this.runDocker(["exec", this.app, "php", "-r", "echo json_encode(opcache_get_configuration());"])).stdout || "{}") as Record + this.provenance = { + schema: "wp-codebox/native-runtime-provenance/v1", backend: "wordpress-native", + php: { version: php[0] || "unknown", sapi: php[1] || "apache2handler" }, + container: { image: WORDPRESS_IMAGE, digest: WORDPRESS_IMAGE.slice(WORDPRESS_IMAGE.indexOf("sha256:")), containment: "required" }, + opcache: { enabled: true, persistent: true, evidence: { configuration: opcache, validate_timestamps: false } }, + httpConcurrency: { workers: 2, model: "Apache prefork: StartServers=2, MinSpareServers=2" }, + database: { integration: "managed-runtime-service", disposable: true }, + browser: { authentication: "fixture-only", credentials: "runtime-generated" }, + benchmarks: { coldStartup: true, warmNoopPhp: true, dynamicWordPressRequest: true }, + representative: { scope: "local", productionRum: false }, + } + return this.provenance + } + + async recordProvenance(provenance: NativeRuntimeProvenance): Promise { + const directory = this.artifactsDirectory() + await mkdir(directory, { recursive: true }) + const path = join(directory, "native-runtime-provenance.json") + const contents = `${JSON.stringify({ ...provenance, fixtureAuthentication: { user: "fixture-admin", generated: true } }, null, 2)}\n` + await writeFile(path, contents, { mode: 0o600 }) + return { path, sha256: createHash("sha256").update(contents).digest("hex") } + } + + async info(): Promise> { return { id: this.id, previewUrl: this.previewUrl } } + + async mount(spec: MountSpec): Promise { + if (spec.mode !== "readonly") throw new Error("wordpress-native mounts must be readonly") + await this.runDocker(["cp", spec.source, `${this.app}:${spec.target}`]) + } + + async execute(spec: ExecutionSpec): Promise { + const startedAt = new Date().toISOString() + let stdout = "" + let stderr = "" + if (spec.command === "wordpress.run-php") { + const code = spec.args?.find((arg) => arg.startsWith("code="))?.slice(5) + if (!code) throw new Error("wordpress.run-php requires code=") + ;({ stdout, stderr } = await this.runDocker(["exec", this.app, "php", "-d", "opcache.enable_cli=1", "-r", code], spec)) + } else if (spec.command === "wordpress.browser-actions") { + const response = await this.dependencies.fetch(this.previewUrl!, { headers: { "X-WP-Codebox-Fixture-Auth": "fixture-admin" } }) + const record = { schema: "wp-codebox/native-browser-network/v1", url: this.previewUrl, status: response.status, authentication: "fixture-only", capturedAt: new Date().toISOString() } + this.browserNetwork.push(record) + stdout = JSON.stringify({ authenticated: true, fixture: "runtime-generated", network: record }) + } else if (spec.command === "wordpress.bench") { + const cold = Date.now() + await this.dependencies.fetch(this.previewUrl!) + const coldStartupMs = Date.now() - cold + const warm = Date.now() + await this.runDocker(["exec", this.app, "php", "-d", "opcache.enable_cli=1", "-r", "echo 'noop';"], spec) + const warmNoopPhpMs = Date.now() - warm + const dynamic = Date.now() + await this.dependencies.fetch(this.previewUrl!) + const dynamicWordPressRequestMs = Date.now() - dynamic + stdout = JSON.stringify({ schema: "wp-codebox/native-benchmark/v1", representative: "local", coldStartupMs, warmNoopPhpMs, dynamicWordPressRequestMs }) + } else throw new Error(`Unsupported wordpress-native command: ${spec.command}`) + const result = { id: randomUUID(), command: spec.command, args: spec.args ?? [], exitCode: 0, stdout, stderr, startedAt, finishedAt: new Date().toISOString() } + this.commands.push({ ...result, environment: undefined }) + return result + } + + async observe(spec: ObservationSpec): Promise { + if (spec.type === "runtime-info") return { type: spec.type, data: { id: this.id, previewUrl: this.previewUrl, provenance: this.provenance }, observedAt: new Date().toISOString() } + return { type: spec.type, data: { previewUrl: this.previewUrl, fixtureAuthentication: "runtime-generated" }, observedAt: new Date().toISOString() } + } + async snapshot(): Promise { return { id: `native-${randomUUID()}`, createdAt: new Date().toISOString(), metadata: { unsupported: "Docker native snapshots are not persisted" } } } + + async collectArtifacts(_spec?: ArtifactSpec): Promise { + const directory = this.artifactsDirectory() + const writer = new ArtifactBundleWriter(directory) + await writer.writeJson("files/native/browser-network.json", this.browserNetwork, { kind: "observations" }) + await writer.writeJson("files/native/commands.json", this.commands, { kind: "commands" }) + const info = { id: this.id, backend: "wordpress-native", environment: this.spec!.environment, createdAt: new Date().toISOString(), status: "created" as const, previewUrl: this.previewUrl } + const manifest = await writer.writeManifest({ id: `native-${this.id}`, contentDigest: { algorithm: "sha256" as const, inputs: [], value: createHash("sha256").update(JSON.stringify(this.commands)).digest("hex") }, createdAt: new Date().toISOString(), runtime: info, files: [] }) + const path = (name: string) => join(directory, name) + return { id: manifest.id, directory, manifestPath: path("manifest.json"), metadataPath: path("native-runtime-provenance.json"), blueprintAfterPath: path("blueprint.after.json"), blueprintAfterNotesPath: path("blueprint.after.notes.md"), eventsPath: path("events.jsonl"), commandsPath: path("files/native/commands.json"), observationsPath: path("files/native/browser-network.json"), runtimeLogPath: path("runtime.log"), commandsLogPath: path("commands.log"), mountsPath: path("mounts.json"), capturedMountsPath: path("captured-mounts.json"), diffsPath: path("diffs.json"), workspacePatchPath: path("workspace.patch"), changedFilesPath: path("changed-files.json"), patchPath: path("patch.diff"), diagnosticsPath: path("diagnostics.json"), testResultsPath: path("test-results.json"), reviewPath: path("review.json"), contentDigest: manifest.contentDigest.value, createdAt: manifest.createdAt } + } + + async destroy(): Promise { + if (!this.destroyPromise) this.destroyPromise = (async () => { + await Promise.all([this.runDocker(["rm", "-f", this.app]).catch(() => undefined), this.runDocker(["rm", "-f", this.database]).catch(() => undefined)]) + await this.runDocker(["network", "rm", this.network]).catch(() => undefined) + await rm(this.root, { recursive: true, force: true }) + this.destroyed = true + })() + await this.destroyPromise + } + + private artifactsDirectory(): string { return this.spec?.artifactsDirectory ?? join(this.root, "artifacts") } + private async waitForHttp(): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + try { if ((await this.dependencies.fetch(this.previewUrl!)).status < 500) return } catch { /* server still starting */ } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new NativeContainmentUnavailableError("Contained WordPress did not become reachable.") + } + private async runDocker(args: string[], spec?: Pick): Promise<{ stdout: string; stderr: string }> { + try { return await this.dependencies.run("docker", args, { timeoutMs: spec?.timeoutMs }) } catch (error) { throw new NativeContainmentUnavailableError(error instanceof Error ? error.message : String(error)) } + } +} + +function nodeDependencies(): DockerNativeRuntimeDependencies { + return { + temporaryDirectory: tmpdir, + async fetch(url, options) { const response = await fetch(url, { headers: options?.headers }); return { status: response.status, body: await response.text() } }, + run(command, args, options = {}) { return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); let stdout = ""; let stderr = "" + const timer = options.timeoutMs ? setTimeout(() => child.kill("SIGTERM"), options.timeoutMs) : undefined + child.stdout.on("data", (value) => { stdout += value }); child.stderr.on("data", (value) => { stderr += value }) + child.on("error", reject); child.on("close", (code) => { if (timer) clearTimeout(timer); code === 0 ? resolve({ stdout, stderr }) : reject(new Error(`${command} ${args[0] ?? ""} failed (${code}): ${stderr}`)) }) + if (options.input) child.stdin.end(options.input); else child.stdin.end() + }) }, + } +} diff --git a/packages/runtime-native/src/index.ts b/packages/runtime-native/src/index.ts index a33d76121..8e9f621d0 100644 --- a/packages/runtime-native/src/index.ts +++ b/packages/runtime-native/src/index.ts @@ -1,2 +1,3 @@ /** Contained native PHP runtime adapter. */ export { NativeRuntimeBackend, NativeRuntimeUnavailableError, createNativeRuntimeBackend, nativeRuntimeBackendProvider, type NativeRuntimeBackendOptions, type NativeRuntimeDriver, type NativeRuntimeDriverFactory, type NativeRuntimeProvenance, type NativeRuntimeProvenanceEvidence } from "./native-runtime.js" +export { createDockerNativeRuntimeDriver, NativeContainmentUnavailableError, type DockerNativeRuntimeDependencies } from "./docker-native-runtime.js" diff --git a/packages/runtime-native/src/native-runtime.ts b/packages/runtime-native/src/native-runtime.ts index 4ffe08912..afd55b0f6 100644 --- a/packages/runtime-native/src/native-runtime.ts +++ b/packages/runtime-native/src/native-runtime.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto" import { assertRuntimeCommandAllowed, assertRuntimeSecretEnvTargetsAvailable, type ArtifactBundle, type ArtifactSpec, type ExecutionResult, type ExecutionSpec, type MountSpec, type ObservationResult, type ObservationSpec, type Runtime, type RuntimeBackend, type RuntimeBackendFactoryContext, type RuntimeBackendProvider, type RuntimeCreateSpec, type RuntimeInfo, type Snapshot } from "@automattic/wp-codebox-core" +import { createDockerNativeRuntimeDriver } from "./docker-native-runtime.js" export interface NativeRuntimeProvenance { schema: "wp-codebox/native-runtime-provenance/v1" @@ -50,14 +51,14 @@ export class NativeRuntimeBackend implements RuntimeBackend { readonly kind = "wordpress-native" as const constructor(private readonly options: NativeRuntimeBackendOptions = {}) {} async create(spec: RuntimeCreateSpec): Promise { - if (!this.options.driver) throw new NativeRuntimeUnavailableError() + const driver = this.options.driver ?? createDockerNativeRuntimeDriver() try { - const provenance = await this.options.driver.create(spec) + const provenance = await driver.create(spec) assertNativeProvenance(provenance) - assertNativeProvenanceEvidence(await this.options.driver.recordProvenance(provenance)) - return new NativeRuntime(spec, this.options.driver) + assertNativeProvenanceEvidence(await driver.recordProvenance(provenance)) + return new NativeRuntime(spec, driver) } catch (error) { - await this.options.driver.destroy().catch(() => undefined) + await driver.destroy().catch(() => undefined) throw error } } diff --git a/tests/native-docker-runtime.integration.test.ts b/tests/native-docker-runtime.integration.test.ts new file mode 100644 index 000000000..ffed02cf1 --- /dev/null +++ b/tests/native-docker-runtime.integration.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createDockerNativeRuntimeDriver } from "@automattic/wp-codebox-native" + +const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8", timeout: 10_000 }) +if (docker.status !== 0) { + console.log("native Docker runtime integration skipped: trusted-containment-tools-unavailable") +} else { + const artifactsDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-native-docker-")) + const driver = createDockerNativeRuntimeDriver() + try { + const provenance = await driver.create({ backend: "wordpress-native", environment: { kind: "wordpress", name: "WordPress", version: "6.8", phpVersion: "8.4" }, policy: { network: "deny", filesystem: "sandbox", commands: ["wordpress.run-php", "wordpress.browser-actions", "wordpress.bench"], secrets: "none", approvals: "never" }, artifactsDirectory }) + assert.equal(provenance.container.containment, "required") + assert.equal((await driver.execute({ command: "wordpress.run-php", args: ["code=echo 'native-php';"] })).stdout.trim(), "native-php") + assert.match((await driver.execute({ command: "wordpress.bench" })).stdout, /dynamicWordPressRequestMs/) + assert.ok((await driver.collectArtifacts()).manifestPath.endsWith("manifest.json")) + console.log("native Docker runtime integration passed") + } finally { + await driver.destroy() + await rm(artifactsDirectory, { recursive: true, force: true }) + } +} diff --git a/tests/native-runtime.test.ts b/tests/native-runtime.test.ts index 82995a379..b40190f3d 100644 --- a/tests/native-runtime.test.ts +++ b/tests/native-runtime.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { createRuntime, type ArtifactBundle, type ExecutionSpec, type RuntimeCreateSpec } from "@automattic/wp-codebox-core" -import { createNativeRuntimeBackend, type NativeRuntimeDriver, type NativeRuntimeProvenance } from "@automattic/wp-codebox-native" +import { createDockerNativeRuntimeDriver, createNativeRuntimeBackend, type NativeRuntimeDriver, type NativeRuntimeProvenance } from "@automattic/wp-codebox-native" const provenance: NativeRuntimeProvenance = { schema: "wp-codebox/native-runtime-provenance/v1", @@ -69,4 +69,30 @@ incompleteBenchmarkDriver.create = async () => ({ ...provenance, benchmarks: { . await assert.rejects(() => createRuntime(spec, createNativeRuntimeBackend({ driver: incompleteBenchmarkDriver })), /benchmark coverage/) assert.deepEqual(incompleteBenchmarkCalls, ["destroy"]) +const dockerCalls: string[] = [] +const docker = createDockerNativeRuntimeDriver({ + temporaryDirectory: () => "/tmp", + async fetch() { return { status: 200, body: "fixture" } }, + async run(command, args) { + dockerCalls.push(`${command} ${args.slice(0, 3).join(" ")}`) + if (args[0] === "port") return { stdout: "127.0.0.1:49152\n", stderr: "" } + if (args.some((arg) => arg.includes("PHP_VERSION"))) return { stdout: "8.4.1\napache2handler", stderr: "" } + if (args.some((arg) => arg.includes("opcache_get_configuration()"))) return { stdout: "{\"directives\":{\"opcache.enable\":true}}", stderr: "" } + return { stdout: "ok", stderr: "" } + }, +}) +const dockerProvenance = await docker.create(spec) +assert.equal(dockerProvenance.container.containment, "required") +assert.equal(dockerProvenance.httpConcurrency.workers, 2) +assert.equal(dockerProvenance.php.sapi, "apache2handler") +await docker.recordProvenance(dockerProvenance) +assert.match((await docker.execute({ command: "wordpress.run-php", args: ["code=echo 'ok';"] })).stdout, /ok/) +assert.match((await docker.execute({ command: "wordpress.browser-actions", args: ["auth=wordpress-admin"] })).stdout, /fixture/) +const benchmark = await docker.execute({ command: "wordpress.bench" }) +assert.match(benchmark.stdout, /warmNoopPhpMs/) +const nativeArtifacts = await docker.collectArtifacts() +assert.match(nativeArtifacts.commandsPath, /files\/native\/commands\.json$/) +await Promise.all([docker.destroy(), docker.destroy()]) +assert.equal(dockerCalls.filter((call) => call.startsWith("docker network rm ")).length, 1) + console.log("native runtime adapter ok") From 7933e4b10d1460eaa265e8057a91aee7e9bb9747 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 14:42:20 -0400 Subject: [PATCH 3/9] fix(runtime): verify native Docker execution --- .../src/docker-native-runtime.ts | 121 +++++++++++++++--- .../native-docker-runtime.integration.test.ts | 17 ++- tests/native-runtime.test.ts | 10 +- 3 files changed, 125 insertions(+), 23 deletions(-) diff --git a/packages/runtime-native/src/docker-native-runtime.ts b/packages/runtime-native/src/docker-native-runtime.ts index 5c9bfeba2..7068b6494 100644 --- a/packages/runtime-native/src/docker-native-runtime.ts +++ b/packages/runtime-native/src/docker-native-runtime.ts @@ -3,16 +3,20 @@ import { createHash, randomBytes, randomUUID } from "node:crypto" import { mkdir, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { ArtifactBundleWriter, type ArtifactBundle, type ArtifactSpec, type ExecutionResult, type ExecutionSpec, type MountSpec, type ObservationResult, type ObservationSpec, type RuntimeCreateSpec, type RuntimeInfo, type Snapshot, resolveRuntimeSecretEnvTargets } from "@automattic/wp-codebox-core" +import { chromium } from "playwright" +import { ArtifactBundleWriter, type ArtifactBundle, type ArtifactSpec, type BrowserInteractionStep, type ExecutionResult, type ExecutionSpec, type MountSpec, type ObservationResult, type ObservationSpec, type RuntimeCreateSpec, type RuntimeInfo, type Snapshot, resolveRuntimeSecretEnvTargets, validateBrowserInteractionScript } from "@automattic/wp-codebox-core" import type { NativeRuntimeDriver, NativeRuntimeProvenance, NativeRuntimeProvenanceEvidence } from "./native-runtime.js" // These immutable references make an accidentally retagged image unable to change a run. -const WORDPRESS_IMAGE = "wordpress@sha256:cc3f3ee1388660fa5bf8158f2267b5126d0c6cd2b98c55e5cbc75182b2e28b84" -const MARIADB_IMAGE = "mariadb@sha256:3cf072264d4e8537099cdf309363cf3a1cb5ee678fd573e46eca1322e9c37095" +// Resolved on the linux/amd64 Docker runner on 2026-09-03. The platform is +// explicit so the digest cannot accidentally select a different manifest. +const DOCKER_PLATFORM = "linux/amd64" +const WORDPRESS_IMAGE = "wordpress:php8.4-apache@sha256:b5ad1a1b6fe6f1232d27a6effb0abc45cf71dcac6d6aba0db7d6fcaec047ffb3" +const MARIADB_IMAGE = "mariadb:11.4@sha256:8fade42367c1d0505a2c06cfacd411e1bd81c28995183d00935e09b702fd0042" export interface DockerNativeRuntimeDependencies { run(command: string, args: string[], options?: { input?: string; timeoutMs?: number }): Promise<{ stdout: string; stderr: string }> - fetch(url: string, options?: { headers?: Record }): Promise<{ status: number; body: string }> + fetch(url: string, options?: { headers?: Record; method?: string; body?: string }): Promise<{ status: number; body: string }> temporaryDirectory(): string } @@ -40,7 +44,12 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { private destroyPromise?: Promise private readonly commands: Array> = [] private readonly browserNetwork: Array> = [] + private readonly browserConsole: Array> = [] + private readonly browserErrors: Array> = [] + private readonly browserSteps: Array> = [] + private readonly browserFiles: Array<{ path: string; kind: string; contentType: string }> = [] private readonly fixturePassword = randomBytes(24).toString("base64url") + private startupMs = 0 constructor(private readonly dependencies: DockerNativeRuntimeDependencies) { this.root = join(dependencies.temporaryDirectory(), this.id) @@ -52,16 +61,19 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { await mkdir(this.root, { recursive: true, mode: 0o700 }) const environment = { ...(spec.runtimeEnv ?? {}), ...resolveRuntimeSecretEnvTargets(spec.secretEnv ?? {}, spec.secretEnvTargets) } await this.runDocker(["network", "create", "--internal", this.network]) - await this.runDocker(["run", "-d", "--name", this.database, "--network", this.network, "--tmpfs", "/var/lib/mysql:rw,noexec,nosuid,size=256m", "-e", `MARIADB_ROOT_PASSWORD=${this.fixturePassword}`, "-e", "MARIADB_DATABASE=wordpress", "-e", "MARIADB_USER=wordpress", "-e", `MARIADB_PASSWORD=${this.fixturePassword}`, MARIADB_IMAGE]) - const appArgs = ["run", "-d", "--name", this.app, "--network", this.network, "-p", "127.0.0.1::80", "-e", "WORDPRESS_DB_HOST=" + this.database, "-e", "WORDPRESS_DB_NAME=wordpress", "-e", "WORDPRESS_DB_USER=wordpress", "-e", `WORDPRESS_DB_PASSWORD=${this.fixturePassword}`, "-e", "WORDPRESS_CONFIG_EXTRA=define('WP_CODEBOX_FIXTURE_AUTH', true);", "-e", "PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d", "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m"] + await this.runDocker(["run", "-d", "--platform", DOCKER_PLATFORM, "--name", this.database, "--network", this.network, "--tmpfs", "/var/lib/mysql:rw,noexec,nosuid,size=256m", "-e", `MARIADB_ROOT_PASSWORD=${this.fixturePassword}`, "-e", "MARIADB_DATABASE=wordpress", "-e", "MARIADB_USER=wordpress", "-e", `MARIADB_PASSWORD=${this.fixturePassword}`, MARIADB_IMAGE]) + const appArgs = ["run", "-d", "--platform", DOCKER_PLATFORM, "--name", this.app, "--network", this.network, "-p", "127.0.0.1::80", "-e", "WORDPRESS_DB_HOST=" + this.database, "-e", "WORDPRESS_DB_NAME=wordpress", "-e", "WORDPRESS_DB_USER=wordpress", "-e", `WORDPRESS_DB_PASSWORD=${this.fixturePassword}`, "-e", "WORDPRESS_CONFIG_EXTRA=define('WP_CODEBOX_FIXTURE_AUTH', true);", "-e", "PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d", "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m"] for (const [name, value] of Object.entries(environment)) appArgs.push("-e", `${name}=${value}`) // Apache's prefork MPM has two permanent child workers; OPcache stays in each worker. appArgs.push(WORDPRESS_IMAGE, "bash", "-lc", "printf '%s\\n' 'opcache.enable=1' 'opcache.enable_cli=1' 'opcache.validate_timestamps=0' 'StartServers 2' 'MinSpareServers 2' 'MaxSpareServers 2' > /usr/local/etc/php/conf.d/zz-codebox-opcache.ini && sed -i 's/StartServers .*/StartServers 2/; s/MinSpareServers .*/MinSpareServers 2/; s/MaxSpareServers .*/MaxSpareServers 2/' /etc/apache2/mods-enabled/mpm_prefork.conf && docker-entrypoint.sh apache2-foreground") + const startupStarted = Date.now() await this.runDocker(appArgs) const port = (await this.runDocker(["port", this.app, "80/tcp"])).stdout.trim().match(/:(\d+)$/)?.[1] if (!port) throw new NativeContainmentUnavailableError("Docker did not publish the contained WordPress HTTP port.") this.previewUrl = `http://127.0.0.1:${port}` await this.waitForHttp() + await this.installFixtureWordPress() + this.startupMs = Math.max(1, Date.now() - startupStarted) const php = (await this.runDocker(["exec", this.app, "php", "-r", "echo PHP_VERSION, '\\n', PHP_SAPI;"])).stdout.trim().split("\n") const opcache = JSON.parse((await this.runDocker(["exec", this.app, "php", "-r", "echo json_encode(opcache_get_configuration());"])).stdout || "{}") as Record this.provenance = { @@ -91,7 +103,14 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { async mount(spec: MountSpec): Promise { if (spec.mode !== "readonly") throw new Error("wordpress-native mounts must be readonly") - await this.runDocker(["cp", spec.source, `${this.app}:${spec.target}`]) + if (spec.type !== "directory") throw new Error("wordpress-native supports readonly directory snapshot mounts only; live and file mounts are not available for running containers") + // Recipes target Playground's /wordpress root; map it to the official + // image root so those downstream distributions do not need a native fork. + const target = spec.target === "/wordpress" ? "/var/www/html" : spec.target.startsWith("/wordpress/") ? `/var/www/html/${spec.target.slice("/wordpress/".length)}` : spec.target + if (target !== "/var/www/html" && !target.startsWith("/var/www/html/")) throw new Error("wordpress-native readonly copies may only target the WordPress root") + // Docker cannot add a bind mount to a running container. This is a one-time, + // readonly-source copy, not a live readonly bind; do not claim otherwise. + await this.runDocker(["cp", `${spec.source}/.`, `${this.app}:${target}`]) } async execute(spec: ExecutionSpec): Promise { @@ -103,19 +122,14 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { if (!code) throw new Error("wordpress.run-php requires code=") ;({ stdout, stderr } = await this.runDocker(["exec", this.app, "php", "-d", "opcache.enable_cli=1", "-r", code], spec)) } else if (spec.command === "wordpress.browser-actions") { - const response = await this.dependencies.fetch(this.previewUrl!, { headers: { "X-WP-Codebox-Fixture-Auth": "fixture-admin" } }) - const record = { schema: "wp-codebox/native-browser-network/v1", url: this.previewUrl, status: response.status, authentication: "fixture-only", capturedAt: new Date().toISOString() } - this.browserNetwork.push(record) - stdout = JSON.stringify({ authenticated: true, fixture: "runtime-generated", network: record }) + stdout = JSON.stringify(await this.runBrowserActions(spec)) } else if (spec.command === "wordpress.bench") { - const cold = Date.now() - await this.dependencies.fetch(this.previewUrl!) - const coldStartupMs = Date.now() - cold + const coldStartupMs = await this.measureColdStartup() const warm = Date.now() await this.runDocker(["exec", this.app, "php", "-d", "opcache.enable_cli=1", "-r", "echo 'noop';"], spec) const warmNoopPhpMs = Date.now() - warm const dynamic = Date.now() - await this.dependencies.fetch(this.previewUrl!) + await this.dependencies.fetch(`${this.previewUrl}/?wp-codebox-dynamic=${randomUUID()}`) const dynamicWordPressRequestMs = Date.now() - dynamic stdout = JSON.stringify({ schema: "wp-codebox/native-benchmark/v1", representative: "local", coldStartupMs, warmNoopPhpMs, dynamicWordPressRequestMs }) } else throw new Error(`Unsupported wordpress-native command: ${spec.command}`) @@ -134,6 +148,11 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { const directory = this.artifactsDirectory() const writer = new ArtifactBundleWriter(directory) await writer.writeJson("files/native/browser-network.json", this.browserNetwork, { kind: "observations" }) + await writer.writeJson("files/browser/network.json", this.browserNetwork, { kind: "browser-network" }) + await writer.writeJson("files/browser/console.json", this.browserConsole, { kind: "browser-console" }) + await writer.writeJson("files/browser/errors.json", this.browserErrors, { kind: "browser-errors" }) + await writer.writeJson("files/browser/steps.json", this.browserSteps, { kind: "browser-steps" }) + for (const file of this.browserFiles) await writer.writeGenerated(file.path, { kind: file.kind, contentType: file.contentType }, async () => undefined) await writer.writeJson("files/native/commands.json", this.commands, { kind: "commands" }) const info = { id: this.id, backend: "wordpress-native", environment: this.spec!.environment, createdAt: new Date().toISOString(), status: "created" as const, previewUrl: this.previewUrl } const manifest = await writer.writeManifest({ id: `native-${this.id}`, contentDigest: { algorithm: "sha256" as const, inputs: [], value: createHash("sha256").update(JSON.stringify(this.commands)).digest("hex") }, createdAt: new Date().toISOString(), runtime: info, files: [] }) @@ -159,6 +178,78 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { } throw new NativeContainmentUnavailableError("Contained WordPress did not become reachable.") } + private async installFixtureWordPress(): Promise { + const body = new URLSearchParams({ weblog_title: "WP Codebox Fixture", user_name: "fixture-admin", admin_password: this.fixturePassword, admin_password2: this.fixturePassword, admin_email: "fixture-admin@example.test", Submit: "Install WordPress", language: "en_US" }).toString() + const installed = await this.dependencies.fetch(`${this.previewUrl}/wp-admin/install.php?step=2`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body }) + if (installed.status >= 400 || !/Success|Log In/i.test(installed.body)) throw new NativeContainmentUnavailableError("Contained WordPress installation did not complete.") + const user = await this.runDocker(["exec", this.app, "php", "-r", "require '/var/www/html/wp-load.php'; echo get_user_by('login', 'fixture-admin') ? 'ready' : 'missing';"]) + if (user.stdout.trim() !== "ready") throw new NativeContainmentUnavailableError("Contained WordPress fixture administrator was not created.") + } + private async measureColdStartup(): Promise { + if (this.startupMs <= 0) throw new NativeContainmentUnavailableError("Contained WordPress startup was not measured.") + return this.startupMs + } + private async runBrowserActions(spec: ExecutionSpec): Promise> { + const raw = spec.args?.find((arg) => arg.startsWith("steps-json="))?.slice("steps-json=".length) + if (!raw) throw new Error("wordpress.browser-actions requires steps-json=") + let parsed: unknown + try { parsed = JSON.parse(raw) } catch { throw new Error("wordpress.browser-actions steps-json must be valid JSON") } + const validation = validateBrowserInteractionScript(parsed) + if (!validation.valid) throw new Error(`wordpress.browser-actions steps-json is invalid: ${validation.issues.map((issue) => `[${issue.index}] ${issue.message}`).join("; ")}`) + const browser = await chromium.launch({ headless: true }) + const page = await browser.newPage() + page.on("console", (message) => this.browserConsole.push({ type: message.type(), text: message.text(), capturedAt: new Date().toISOString() })) + page.on("pageerror", (error) => this.browserErrors.push({ message: error.message, capturedAt: new Date().toISOString() })) + page.on("response", (response) => this.browserNetwork.push({ schema: "wp-codebox/native-browser-network/v1", url: response.url(), status: response.status(), method: response.request().method(), capturedAt: new Date().toISOString() })) + try { + await page.goto(`${this.previewUrl}/wp-login.php`, { waitUntil: "load" }) + await page.locator("#user_login").fill("fixture-admin") + await page.locator("#user_pass").fill(this.fixturePassword) + await page.locator("#wp-submit").click() + await page.waitForURL(/wp-admin/, { timeout: 15_000 }) + for (const [index, step] of validation.steps.entries()) await this.runBrowserStep(page, step, index) + return { schema: "wp-codebox/native-browser-actions/v1", authenticated: true, fixture: "runtime-generated", steps: validation.steps.length, network: this.browserNetwork.slice(-100) } + } finally { await browser.close() } + } + private async runBrowserStep(page: import("playwright").Page, step: BrowserInteractionStep, index: number): Promise { + const started = Date.now() + const selector = step.selector ?? (step.text ? `text=${step.text}` : undefined) + try { + if (step.kind === "navigate") await page.goto(new URL(step.url ?? "/", this.previewUrl).toString(), { waitUntil: step.waitFor === "load" || step.waitFor === "networkidle" ? step.waitFor : "domcontentloaded" }) + else if (step.kind === "click") await page.locator(selector!).click() + else if (step.kind === "fill") await page.locator(selector!).fill(step.value ?? "") + else if (step.kind === "type") await page.locator(selector!).pressSequentially(step.value ?? "") + else if (step.kind === "press") await page.locator(selector!).press(step.key!) + else if (step.kind === "drag") { + if (!step.from || !step.to || !("selector" in step.to)) throw new Error("native browser drag requires selector source and target") + await page.locator(step.from).dragTo(page.locator(step.to.selector)) + } + else if (step.kind === "hover") await page.locator(selector!).hover() + else if (step.kind === "select") await page.locator(selector!).selectOption(step.values ?? step.value ?? "") + else if (step.kind === "waitFor") { + if (step.duration) await page.waitForTimeout(Number.parseFloat(step.duration) * (step.duration.endsWith("s") && !step.duration.endsWith("ms") ? 1000 : 1)) + else if (step.waitFor === "load" || step.waitFor === "networkidle" || step.waitFor === "domcontentloaded") await page.waitForLoadState(step.waitFor) + else await page.locator((step.waitFor ?? "").replace(/^selector:/, "")).waitFor() + } + else if (step.kind === "evaluate") { const value = await page.evaluate(step.expression!); if (step.assert !== undefined && JSON.stringify(value) !== JSON.stringify(step.assert)) throw new Error("evaluate assertion failed") } + else if (step.kind === "expect") await this.assertBrowserState(page, selector!, step.state ?? "visible") + else if (step.kind === "screenshot") { const path = `files/browser/${step.name ?? `step-${index}`}.png`; await mkdir(join(this.artifactsDirectory(), "files", "browser"), { recursive: true }); await page.screenshot({ path: join(this.artifactsDirectory(), path) }); this.browserFiles.push({ path, kind: "browser-screenshot", contentType: "image/png" }) } + else if (step.kind === "capture") { const path = `files/browser/capture-${index}.html`; await mkdir(join(this.artifactsDirectory(), "files", "browser"), { recursive: true }); await writeFile(join(this.artifactsDirectory(), path), await page.content(), { mode: 0o600 }); this.browserFiles.push({ path, kind: "browser-html-snapshot", contentType: "text/html; charset=utf-8" }) } + else if (step.kind === "assertObservation") { + if (step.assertion === "no-console-errors" && this.browserConsole.some(({ type }) => type === "error")) throw new Error("console errors were captured") + if (step.assertion === "no-page-errors" && this.browserErrors.length > 0) throw new Error("page errors were captured") + if (step.assertion !== "no-console-errors" && step.assertion !== "no-page-errors") throw new Error(`native browser assertion is unsupported: ${step.assertion}`) + } + else throw new Error(`wordpress.browser-actions native executor does not support ${step.kind}`) + this.browserSteps.push({ index, kind: step.kind, status: "passed", durationMs: Date.now() - started }) + } catch (error) { this.browserSteps.push({ index, kind: step.kind, status: "failed", durationMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) }); throw error } + } + private async assertBrowserState(page: import("playwright").Page, selector: string, state: NonNullable): Promise { + const locator = page.locator(selector) + if (state === "visible" || state === "hidden" || state === "attached" || state === "detached") { await locator.waitFor({ state }); return } + const actual = state === "enabled" ? await locator.isEnabled() : state === "disabled" ? !(await locator.isEnabled()) : state === "checked" ? await locator.isChecked() : state === "unchecked" ? !(await locator.isChecked()) : await locator.isEditable() + if (!actual) throw new Error(`expected ${selector} to be ${state}`) + } private async runDocker(args: string[], spec?: Pick): Promise<{ stdout: string; stderr: string }> { try { return await this.dependencies.run("docker", args, { timeoutMs: spec?.timeoutMs }) } catch (error) { throw new NativeContainmentUnavailableError(error instanceof Error ? error.message : String(error)) } } diff --git a/tests/native-docker-runtime.integration.test.ts b/tests/native-docker-runtime.integration.test.ts index ffed02cf1..f3592a0b4 100644 --- a/tests/native-docker-runtime.integration.test.ts +++ b/tests/native-docker-runtime.integration.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { spawnSync } from "node:child_process" -import { mkdtemp, rm } from "node:fs/promises" +import { mkdtemp, readFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { createDockerNativeRuntimeDriver } from "@automattic/wp-codebox-native" @@ -15,11 +15,18 @@ if (docker.status !== 0) { const provenance = await driver.create({ backend: "wordpress-native", environment: { kind: "wordpress", name: "WordPress", version: "6.8", phpVersion: "8.4" }, policy: { network: "deny", filesystem: "sandbox", commands: ["wordpress.run-php", "wordpress.browser-actions", "wordpress.bench"], secrets: "none", approvals: "never" }, artifactsDirectory }) assert.equal(provenance.container.containment, "required") assert.equal((await driver.execute({ command: "wordpress.run-php", args: ["code=echo 'native-php';"] })).stdout.trim(), "native-php") - assert.match((await driver.execute({ command: "wordpress.bench" })).stdout, /dynamicWordPressRequestMs/) - assert.ok((await driver.collectArtifacts()).manifestPath.endsWith("manifest.json")) - console.log("native Docker runtime integration passed") + const browser = await driver.execute({ command: "wordpress.browser-actions", args: ["auth=wordpress-admin", `steps-json=${JSON.stringify([{ kind: "navigate", url: "/wp-admin/", waitFor: "load" }, { kind: "expect", selector: "#wpadminbar", state: "visible" }])}`, "capture=steps,network,console,errors"] }) + assert.match(browser.stdout, /"authenticated":true/) + const benchmark = await driver.execute({ command: "wordpress.bench" }) + assert.match(benchmark.stdout, /coldStartupMs/) + assert.match(benchmark.stdout, /warmNoopPhpMs/) + assert.match(benchmark.stdout, /dynamicWordPressRequestMs/) + const artifacts = await driver.collectArtifacts() + assert.ok(artifacts.manifestPath.endsWith("manifest.json")) + assert.match(await readFile(artifacts.observationsPath, "utf8"), /wp-admin/) + assert.match(await readFile(join(artifacts.directory, "files/browser/steps.json"), "utf8"), /"passed"/) + console.log(`native Docker runtime integration passed; reviewer artifacts retained at ${artifactsDirectory}`) } finally { await driver.destroy() - await rm(artifactsDirectory, { recursive: true, force: true }) } } diff --git a/tests/native-runtime.test.ts b/tests/native-runtime.test.ts index b40190f3d..47608138b 100644 --- a/tests/native-runtime.test.ts +++ b/tests/native-runtime.test.ts @@ -72,12 +72,13 @@ assert.deepEqual(incompleteBenchmarkCalls, ["destroy"]) const dockerCalls: string[] = [] const docker = createDockerNativeRuntimeDriver({ temporaryDirectory: () => "/tmp", - async fetch() { return { status: 200, body: "fixture" } }, + async fetch(url) { return { status: 200, body: url.includes("install.php") ? "Success! Log In" : "fixture" } }, async run(command, args) { - dockerCalls.push(`${command} ${args.slice(0, 3).join(" ")}`) + dockerCalls.push(`${command} ${args.join(" ")}`) if (args[0] === "port") return { stdout: "127.0.0.1:49152\n", stderr: "" } if (args.some((arg) => arg.includes("PHP_VERSION"))) return { stdout: "8.4.1\napache2handler", stderr: "" } if (args.some((arg) => arg.includes("opcache_get_configuration()"))) return { stdout: "{\"directives\":{\"opcache.enable\":true}}", stderr: "" } + if (args.some((arg) => arg.includes("get_user_by"))) return { stdout: "ready", stderr: "" } return { stdout: "ok", stderr: "" } }, }) @@ -85,9 +86,12 @@ const dockerProvenance = await docker.create(spec) assert.equal(dockerProvenance.container.containment, "required") assert.equal(dockerProvenance.httpConcurrency.workers, 2) assert.equal(dockerProvenance.php.sapi, "apache2handler") +assert.ok(dockerCalls.some((call) => call.includes("--platform linux/amd64") && call.includes("mariadb:11.4@sha256:8fade42367c1d0505a2c06cfacd411e1bd81c28995183d00935e09b702fd0042"))) +assert.ok(dockerCalls.some((call) => call.includes("--platform linux/amd64") && call.includes("wordpress:php8.4-apache@sha256:b5ad1a1b6fe6f1232d27a6effb0abc45cf71dcac6d6aba0db7d6fcaec047ffb3"))) await docker.recordProvenance(dockerProvenance) +await docker.mount({ type: "directory", source: "/fixture", target: "/wordpress/wp-content/plugins/fixture", mode: "readonly" }) +assert.ok(dockerCalls.some((call) => call.includes("cp /fixture/. ") && call.includes(":/var/www/html/wp-content/plugins/fixture"))) assert.match((await docker.execute({ command: "wordpress.run-php", args: ["code=echo 'ok';"] })).stdout, /ok/) -assert.match((await docker.execute({ command: "wordpress.browser-actions", args: ["auth=wordpress-admin"] })).stdout, /fixture/) const benchmark = await docker.execute({ command: "wordpress.bench" }) assert.match(benchmark.stdout, /warmNoopPhpMs/) const nativeArtifacts = await docker.collectArtifacts() From 9e60eb51ab47be9e82e6272394a3e29c37fa22be Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 14:51:37 -0400 Subject: [PATCH 4/9] fix(runtime): diagnose native Docker startup --- .../src/docker-native-runtime.ts | 33 ++++++++++++++-- tests/native-runtime.test.ts | 39 ++++++++++++++++++- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/runtime-native/src/docker-native-runtime.ts b/packages/runtime-native/src/docker-native-runtime.ts index 7068b6494..8c0d36897 100644 --- a/packages/runtime-native/src/docker-native-runtime.ts +++ b/packages/runtime-native/src/docker-native-runtime.ts @@ -62,14 +62,16 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { const environment = { ...(spec.runtimeEnv ?? {}), ...resolveRuntimeSecretEnvTargets(spec.secretEnv ?? {}, spec.secretEnvTargets) } await this.runDocker(["network", "create", "--internal", this.network]) await this.runDocker(["run", "-d", "--platform", DOCKER_PLATFORM, "--name", this.database, "--network", this.network, "--tmpfs", "/var/lib/mysql:rw,noexec,nosuid,size=256m", "-e", `MARIADB_ROOT_PASSWORD=${this.fixturePassword}`, "-e", "MARIADB_DATABASE=wordpress", "-e", "MARIADB_USER=wordpress", "-e", `MARIADB_PASSWORD=${this.fixturePassword}`, MARIADB_IMAGE]) - const appArgs = ["run", "-d", "--platform", DOCKER_PLATFORM, "--name", this.app, "--network", this.network, "-p", "127.0.0.1::80", "-e", "WORDPRESS_DB_HOST=" + this.database, "-e", "WORDPRESS_DB_NAME=wordpress", "-e", "WORDPRESS_DB_USER=wordpress", "-e", `WORDPRESS_DB_PASSWORD=${this.fixturePassword}`, "-e", "WORDPRESS_CONFIG_EXTRA=define('WP_CODEBOX_FIXTURE_AUTH', true);", "-e", "PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d", "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m"] + const appArgs = ["run", "-d", "--platform", DOCKER_PLATFORM, "--name", this.app, "--network", this.network, "--publish", "127.0.0.1:0:80/tcp", "-e", "WORDPRESS_DB_HOST=" + this.database, "-e", "WORDPRESS_DB_NAME=wordpress", "-e", "WORDPRESS_DB_USER=wordpress", "-e", `WORDPRESS_DB_PASSWORD=${this.fixturePassword}`, "-e", "WORDPRESS_CONFIG_EXTRA=define('WP_CODEBOX_FIXTURE_AUTH', true);", "-e", "PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d", "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m", "--entrypoint", "bash"] for (const [name, value] of Object.entries(environment)) appArgs.push("-e", `${name}=${value}`) // Apache's prefork MPM has two permanent child workers; OPcache stays in each worker. - appArgs.push(WORDPRESS_IMAGE, "bash", "-lc", "printf '%s\\n' 'opcache.enable=1' 'opcache.enable_cli=1' 'opcache.validate_timestamps=0' 'StartServers 2' 'MinSpareServers 2' 'MaxSpareServers 2' > /usr/local/etc/php/conf.d/zz-codebox-opcache.ini && sed -i 's/StartServers .*/StartServers 2/; s/MinSpareServers .*/MinSpareServers 2/; s/MaxSpareServers .*/MaxSpareServers 2/' /etc/apache2/mods-enabled/mpm_prefork.conf && docker-entrypoint.sh apache2-foreground") + appArgs.push(WORDPRESS_IMAGE, "-lc", "printf '%s\\n' 'opcache.enable=1' 'opcache.enable_cli=1' 'opcache.validate_timestamps=0' 'StartServers 2' 'MinSpareServers 2' 'MaxSpareServers 2' > /usr/local/etc/php/conf.d/zz-codebox-opcache.ini && sed -i 's/StartServers .*/StartServers 2/; s/MinSpareServers .*/MinSpareServers 2/; s/MaxSpareServers .*/MaxSpareServers 2/' /etc/apache2/mods-enabled/mpm_prefork.conf && exec /usr/local/bin/docker-entrypoint.sh apache2-foreground") const startupStarted = Date.now() await this.runDocker(appArgs) - const port = (await this.runDocker(["port", this.app, "80/tcp"])).stdout.trim().match(/:(\d+)$/)?.[1] - if (!port) throw new NativeContainmentUnavailableError("Docker did not publish the contained WordPress HTTP port.") + const startup = await this.runDocker(["inspect", "--format", "{{.State.Status}}\n{{.State.ExitCode}}\n{{json .NetworkSettings.Ports}}", this.app]) + const [status = "unknown", exitCode = "unknown", portBindings = ""] = startup.stdout.trim().split("\n") + const port = this.localhostPort(portBindings) + if (status !== "running" || !port) throw await this.startupFailure(`state=${status} exitCode=${exitCode} portBindings=${portBindings || "unavailable"}`) this.previewUrl = `http://127.0.0.1:${port}` await this.waitForHttp() await this.installFixtureWordPress() @@ -178,6 +180,29 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { } throw new NativeContainmentUnavailableError("Contained WordPress did not become reachable.") } + private localhostPort(portBindings: string): string | undefined { + try { + const bindings = JSON.parse(portBindings) as Record | null> + return bindings["80/tcp"]?.find((binding) => binding.HostIp === "127.0.0.1" && /^\d+$/.test(binding.HostPort ?? ""))?.HostPort + } catch { return undefined } + } + private async startupFailure(reason: string): Promise { + const [inspect, logs] = await Promise.all([ + this.diagnosticDocker(["inspect", "--format", "status={{.State.Status}} exitCode={{.State.ExitCode}} ports={{json .NetworkSettings.Ports}}", this.app]), + this.diagnosticDocker(["logs", "--tail", "100", this.app]), + ]) + return new NativeContainmentUnavailableError(`Contained WordPress startup failed: ${reason}; docker inspect: ${inspect}; docker logs: ${logs}`) + } + private async diagnosticDocker(args: string[]): Promise { + try { + const { stdout, stderr } = await this.dependencies.run("docker", args) + return this.boundedDiagnostic([stdout, stderr].filter(Boolean).join("\n")) + } catch (error) { return this.boundedDiagnostic(error instanceof Error ? error.message : String(error)) } + } + private boundedDiagnostic(value: string): string { + const normalized = value.trim().replace(/\s+/g, " ") + return normalized ? normalized.slice(0, 4_000) : "none" + } private async installFixtureWordPress(): Promise { const body = new URLSearchParams({ weblog_title: "WP Codebox Fixture", user_name: "fixture-admin", admin_password: this.fixturePassword, admin_password2: this.fixturePassword, admin_email: "fixture-admin@example.test", Submit: "Install WordPress", language: "en_US" }).toString() const installed = await this.dependencies.fetch(`${this.previewUrl}/wp-admin/install.php?step=2`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body }) diff --git a/tests/native-runtime.test.ts b/tests/native-runtime.test.ts index 47608138b..0cb9b79ce 100644 --- a/tests/native-runtime.test.ts +++ b/tests/native-runtime.test.ts @@ -70,12 +70,14 @@ await assert.rejects(() => createRuntime(spec, createNativeRuntimeBackend({ driv assert.deepEqual(incompleteBenchmarkCalls, ["destroy"]) const dockerCalls: string[] = [] +const dockerArgs: string[][] = [] const docker = createDockerNativeRuntimeDriver({ temporaryDirectory: () => "/tmp", async fetch(url) { return { status: 200, body: url.includes("install.php") ? "Success! Log In" : "fixture" } }, async run(command, args) { + dockerArgs.push(args) dockerCalls.push(`${command} ${args.join(" ")}`) - if (args[0] === "port") return { stdout: "127.0.0.1:49152\n", stderr: "" } + if (args[0] === "inspect") return { stdout: "running\n0\n{\"80/tcp\":[{\"HostIp\":\"127.0.0.1\",\"HostPort\":\"49152\"}]}\n", stderr: "" } if (args.some((arg) => arg.includes("PHP_VERSION"))) return { stdout: "8.4.1\napache2handler", stderr: "" } if (args.some((arg) => arg.includes("opcache_get_configuration()"))) return { stdout: "{\"directives\":{\"opcache.enable\":true}}", stderr: "" } if (args.some((arg) => arg.includes("get_user_by"))) return { stdout: "ready", stderr: "" } @@ -88,6 +90,11 @@ assert.equal(dockerProvenance.httpConcurrency.workers, 2) assert.equal(dockerProvenance.php.sapi, "apache2handler") assert.ok(dockerCalls.some((call) => call.includes("--platform linux/amd64") && call.includes("mariadb:11.4@sha256:8fade42367c1d0505a2c06cfacd411e1bd81c28995183d00935e09b702fd0042"))) assert.ok(dockerCalls.some((call) => call.includes("--platform linux/amd64") && call.includes("wordpress:php8.4-apache@sha256:b5ad1a1b6fe6f1232d27a6effb0abc45cf71dcac6d6aba0db7d6fcaec047ffb3"))) +const appRunArgs = dockerArgs.find((args) => args[0] === "run" && args.some((arg) => arg.startsWith("wordpress:php8.4-apache@"))) +assert.equal(appRunArgs?.[appRunArgs.indexOf("--publish") + 1], "127.0.0.1:0:80/tcp") +assert.equal(appRunArgs?.[appRunArgs.indexOf("--entrypoint") + 1], "bash") +assert.match(appRunArgs?.at(-1) ?? "", /exec \/usr\/local\/bin\/docker-entrypoint\.sh apache2-foreground/) +assert.equal(dockerArgs.some((args) => args[0] === "port"), false) await docker.recordProvenance(dockerProvenance) await docker.mount({ type: "directory", source: "/fixture", target: "/wordpress/wp-content/plugins/fixture", mode: "readonly" }) assert.ok(dockerCalls.some((call) => call.includes("cp /fixture/. ") && call.includes(":/var/www/html/wp-content/plugins/fixture"))) @@ -99,4 +106,34 @@ assert.match(nativeArtifacts.commandsPath, /files\/native\/commands\.json$/) await Promise.all([docker.destroy(), docker.destroy()]) assert.equal(dockerCalls.filter((call) => call.startsWith("docker network rm ")).length, 1) +const failedDockerArgs: string[][] = [] +const failedDocker = createDockerNativeRuntimeDriver({ + temporaryDirectory: () => "/tmp", + async fetch() { return { status: 200, body: "fixture" } }, + async run(_command, args) { + failedDockerArgs.push(args) + if (args[0] === "inspect") return { stdout: "exited\n1\n{\"80/tcp\":[{\"HostIp\":\"127.0.0.1\",\"HostPort\":\"49152\"}]}\n", stderr: "" } + if (args[0] === "logs") return { stdout: "apache2: configuration error", stderr: "" } + return { stdout: "ok", stderr: "" } + }, +}) +await assert.rejects(() => failedDocker.create(spec), /state=exited exitCode=1 portBindings=\{"80\/tcp":\[\{"HostIp":"127\.0\.0\.1","HostPort":"49152"\}\]\}; docker inspect: exited 1 \{"80\/tcp":\[\{"HostIp":"127\.0\.0\.1","HostPort":"49152"\}\]\}; docker logs: apache2: configuration error/) +assert.deepEqual(failedDockerArgs.filter((args) => args[0] === "inspect").map((args) => args.slice(0, 3)), [["inspect", "--format", "{{.State.Status}}\n{{.State.ExitCode}}\n{{json .NetworkSettings.Ports}}"], ["inspect", "--format", "status={{.State.Status}} exitCode={{.State.ExitCode}} ports={{json .NetworkSettings.Ports}}"]]) +assert.deepEqual(failedDockerArgs.find((args) => args[0] === "logs")?.slice(0, 3), ["logs", "--tail", "100"]) +await failedDocker.destroy() +assert.equal(failedDockerArgs.filter((args) => args[0] === "rm" && args[1] === "-f").length, 2) +assert.equal(failedDockerArgs.filter((args) => args[0] === "network" && args[1] === "rm").length, 1) + +const missingBindingDocker = createDockerNativeRuntimeDriver({ + temporaryDirectory: () => "/tmp", + async fetch() { return { status: 200, body: "fixture" } }, + async run(_command, args) { + if (args[0] === "inspect") return { stdout: "running\n0\n{\"80/tcp\":null}\n", stderr: "" } + if (args[0] === "logs") return { stdout: "waiting for database", stderr: "" } + return { stdout: "ok", stderr: "" } + }, +}) +await assert.rejects(() => missingBindingDocker.create(spec), /state=running exitCode=0 portBindings=\{"80\/tcp":null\}.*docker inspect: running 0 \{"80\/tcp":null\}; docker logs: waiting for database/) +await missingBindingDocker.destroy() + console.log("native runtime adapter ok") From e5493db7674e6c1e5794b233b639e3f27f795809 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 15:01:35 -0400 Subject: [PATCH 5/9] fix(runtime): publish native preview off internal network --- .../runtime-native/src/docker-native-runtime.ts | 13 +++++++++++-- tests/native-runtime.test.ts | 12 ++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/runtime-native/src/docker-native-runtime.ts b/packages/runtime-native/src/docker-native-runtime.ts index 8c0d36897..c8659d267 100644 --- a/packages/runtime-native/src/docker-native-runtime.ts +++ b/packages/runtime-native/src/docker-native-runtime.ts @@ -34,6 +34,7 @@ export function createDockerNativeRuntimeDriver(dependencies: DockerNativeRuntim class DockerNativeRuntimeDriver implements NativeRuntimeDriver { private readonly id = `wp-codebox-native-${randomUUID()}` private readonly network = `${this.id}-network` + private readonly appNetwork = `${this.id}-app-network` private readonly database = `${this.id}-db` private readonly app = `${this.id}-app` private readonly root: string @@ -60,14 +61,22 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { await this.runDocker(["version", "--format", "{{.Server.Version}}"]) await mkdir(this.root, { recursive: true, mode: 0o700 }) const environment = { ...(spec.runtimeEnv ?? {}), ...resolveRuntimeSecretEnvTargets(spec.secretEnv ?? {}, spec.secretEnvTargets) } + // Docker refuses to publish host ports for containers attached only to an + // `--internal` network. The database therefore stays on the internal + // network, and the app additionally joins a publishable network so the + // preview URL is reachable on loopback only. await this.runDocker(["network", "create", "--internal", this.network]) + await this.runDocker(["network", "create", this.appNetwork]) await this.runDocker(["run", "-d", "--platform", DOCKER_PLATFORM, "--name", this.database, "--network", this.network, "--tmpfs", "/var/lib/mysql:rw,noexec,nosuid,size=256m", "-e", `MARIADB_ROOT_PASSWORD=${this.fixturePassword}`, "-e", "MARIADB_DATABASE=wordpress", "-e", "MARIADB_USER=wordpress", "-e", `MARIADB_PASSWORD=${this.fixturePassword}`, MARIADB_IMAGE]) - const appArgs = ["run", "-d", "--platform", DOCKER_PLATFORM, "--name", this.app, "--network", this.network, "--publish", "127.0.0.1:0:80/tcp", "-e", "WORDPRESS_DB_HOST=" + this.database, "-e", "WORDPRESS_DB_NAME=wordpress", "-e", "WORDPRESS_DB_USER=wordpress", "-e", `WORDPRESS_DB_PASSWORD=${this.fixturePassword}`, "-e", "WORDPRESS_CONFIG_EXTRA=define('WP_CODEBOX_FIXTURE_AUTH', true);", "-e", "PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d", "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m", "--entrypoint", "bash"] + const appArgs = ["run", "-d", "--platform", DOCKER_PLATFORM, "--name", this.app, "--network", this.appNetwork, "--publish", "127.0.0.1:0:80/tcp", "-e", "WORDPRESS_DB_HOST=" + this.database, "-e", "WORDPRESS_DB_NAME=wordpress", "-e", "WORDPRESS_DB_USER=wordpress", "-e", `WORDPRESS_DB_PASSWORD=${this.fixturePassword}`, "-e", "WORDPRESS_CONFIG_EXTRA=define('WP_CODEBOX_FIXTURE_AUTH', true);", "-e", "PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d", "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m", "--entrypoint", "bash"] for (const [name, value] of Object.entries(environment)) appArgs.push("-e", `${name}=${value}`) // Apache's prefork MPM has two permanent child workers; OPcache stays in each worker. appArgs.push(WORDPRESS_IMAGE, "-lc", "printf '%s\\n' 'opcache.enable=1' 'opcache.enable_cli=1' 'opcache.validate_timestamps=0' 'StartServers 2' 'MinSpareServers 2' 'MaxSpareServers 2' > /usr/local/etc/php/conf.d/zz-codebox-opcache.ini && sed -i 's/StartServers .*/StartServers 2/; s/MinSpareServers .*/MinSpareServers 2/; s/MaxSpareServers .*/MaxSpareServers 2/' /etc/apache2/mods-enabled/mpm_prefork.conf && exec /usr/local/bin/docker-entrypoint.sh apache2-foreground") const startupStarted = Date.now() await this.runDocker(appArgs) + // Join the isolated database network after creation; only this container + // may reach MariaDB, and MariaDB itself never gains outbound access. + await this.runDocker(["network", "connect", this.network, this.app]) const startup = await this.runDocker(["inspect", "--format", "{{.State.Status}}\n{{.State.ExitCode}}\n{{json .NetworkSettings.Ports}}", this.app]) const [status = "unknown", exitCode = "unknown", portBindings = ""] = startup.stdout.trim().split("\n") const port = this.localhostPort(portBindings) @@ -165,7 +174,7 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { async destroy(): Promise { if (!this.destroyPromise) this.destroyPromise = (async () => { await Promise.all([this.runDocker(["rm", "-f", this.app]).catch(() => undefined), this.runDocker(["rm", "-f", this.database]).catch(() => undefined)]) - await this.runDocker(["network", "rm", this.network]).catch(() => undefined) + await Promise.all([this.runDocker(["network", "rm", this.network]).catch(() => undefined), this.runDocker(["network", "rm", this.appNetwork]).catch(() => undefined)]) await rm(this.root, { recursive: true, force: true }) this.destroyed = true })() diff --git a/tests/native-runtime.test.ts b/tests/native-runtime.test.ts index 0cb9b79ce..80855effa 100644 --- a/tests/native-runtime.test.ts +++ b/tests/native-runtime.test.ts @@ -95,6 +95,14 @@ assert.equal(appRunArgs?.[appRunArgs.indexOf("--publish") + 1], "127.0.0.1:0:80/ assert.equal(appRunArgs?.[appRunArgs.indexOf("--entrypoint") + 1], "bash") assert.match(appRunArgs?.at(-1) ?? "", /exec \/usr\/local\/bin\/docker-entrypoint\.sh apache2-foreground/) assert.equal(dockerArgs.some((args) => args[0] === "port"), false) +// Publishing requires a non-internal network, so the app publishes on its own +// network and joins the internal database network afterwards. +const appNetwork = appRunArgs?.[appRunArgs.indexOf("--network") + 1] +const internalNetwork = dockerArgs.find((args) => args[0] === "network" && args[1] === "create" && args.includes("--internal"))?.at(-1) +assert.ok(appNetwork && internalNetwork && appNetwork !== internalNetwork) +assert.ok(dockerArgs.some((args) => args[0] === "network" && args[1] === "create" && !args.includes("--internal") && args.at(-1) === appNetwork)) +assert.ok(dockerArgs.some((args) => args[0] === "network" && args[1] === "connect" && args[2] === internalNetwork)) +assert.equal(dockerArgs.find((args) => args[0] === "run" && args.some((arg) => arg.startsWith("mariadb:11.4@")))?.includes(appNetwork), false) await docker.recordProvenance(dockerProvenance) await docker.mount({ type: "directory", source: "/fixture", target: "/wordpress/wp-content/plugins/fixture", mode: "readonly" }) assert.ok(dockerCalls.some((call) => call.includes("cp /fixture/. ") && call.includes(":/var/www/html/wp-content/plugins/fixture"))) @@ -104,7 +112,7 @@ assert.match(benchmark.stdout, /warmNoopPhpMs/) const nativeArtifacts = await docker.collectArtifacts() assert.match(nativeArtifacts.commandsPath, /files\/native\/commands\.json$/) await Promise.all([docker.destroy(), docker.destroy()]) -assert.equal(dockerCalls.filter((call) => call.startsWith("docker network rm ")).length, 1) +assert.deepEqual(dockerArgs.filter((args) => args[0] === "network" && args[1] === "rm").map((args) => args.at(-1)).sort(), [appNetwork, internalNetwork].sort()) const failedDockerArgs: string[][] = [] const failedDocker = createDockerNativeRuntimeDriver({ @@ -122,7 +130,7 @@ assert.deepEqual(failedDockerArgs.filter((args) => args[0] === "inspect").map((a assert.deepEqual(failedDockerArgs.find((args) => args[0] === "logs")?.slice(0, 3), ["logs", "--tail", "100"]) await failedDocker.destroy() assert.equal(failedDockerArgs.filter((args) => args[0] === "rm" && args[1] === "-f").length, 2) -assert.equal(failedDockerArgs.filter((args) => args[0] === "network" && args[1] === "rm").length, 1) +assert.equal(failedDockerArgs.filter((args) => args[0] === "network" && args[1] === "rm").length, 2) const missingBindingDocker = createDockerNativeRuntimeDriver({ temporaryDirectory: () => "/tmp", From 00a4c5f03b5652892c2cd4454b9c50ac5ef3583e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 15:03:55 -0400 Subject: [PATCH 6/9] fix(runtime): install native WordPress deterministically --- .../src/docker-native-runtime.ts | 25 +++++++++++++++---- tests/native-runtime.test.ts | 3 ++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/runtime-native/src/docker-native-runtime.ts b/packages/runtime-native/src/docker-native-runtime.ts index c8659d267..b90e844d0 100644 --- a/packages/runtime-native/src/docker-native-runtime.ts +++ b/packages/runtime-native/src/docker-native-runtime.ts @@ -212,12 +212,27 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { const normalized = value.trim().replace(/\s+/g, " ") return normalized ? normalized.slice(0, 4_000) : "none" } + /// The database container accepts TCP before it can serve queries, so prove + /// WordPress itself can connect before installing. + private async waitForDatabase(): Promise { + const probe = "define('WP_INSTALLING', true); require '/var/www/html/wp-load.php'; echo $GLOBALS['wpdb']->check_connection(false) ? 'ready' : 'unavailable';" + for (let attempt = 0; attempt < 60; attempt += 1) { + const observed = await this.diagnosticDocker(["exec", this.app, "php", "-r", probe]) + if (observed.includes("ready")) return + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + throw new NativeContainmentUnavailableError(`Contained database never accepted WordPress connections; docker logs: ${await this.diagnosticDocker(["logs", "--tail", "100", this.database])}`) + } + /// Install through WordPress's own API rather than the localized HTML form, + /// so installation cannot silently depend on markup or locale. private async installFixtureWordPress(): Promise { - const body = new URLSearchParams({ weblog_title: "WP Codebox Fixture", user_name: "fixture-admin", admin_password: this.fixturePassword, admin_password2: this.fixturePassword, admin_email: "fixture-admin@example.test", Submit: "Install WordPress", language: "en_US" }).toString() - const installed = await this.dependencies.fetch(`${this.previewUrl}/wp-admin/install.php?step=2`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body }) - if (installed.status >= 400 || !/Success|Log In/i.test(installed.body)) throw new NativeContainmentUnavailableError("Contained WordPress installation did not complete.") - const user = await this.runDocker(["exec", this.app, "php", "-r", "require '/var/www/html/wp-load.php'; echo get_user_by('login', 'fixture-admin') ? 'ready' : 'missing';"]) - if (user.stdout.trim() !== "ready") throw new NativeContainmentUnavailableError("Contained WordPress fixture administrator was not created.") + await this.waitForDatabase() + const install = "define('WP_INSTALLING', true); require '/var/www/html/wp-load.php'; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; if (!is_blog_installed()) { $result = wp_install('WP Codebox Fixture', 'fixture-admin', 'fixture-admin@example.test', false, '', getenv('WP_CODEBOX_FIXTURE_PASSWORD')); if (is_wp_error($result)) { echo 'install-error: ', $result->get_error_message(); exit(1); } } echo get_user_by('login', 'fixture-admin') && is_blog_installed() ? 'ready' : 'missing';" + const installed = await this.diagnosticDocker(["exec", "-e", `WP_CODEBOX_FIXTURE_PASSWORD=${this.fixturePassword}`, this.app, "php", "-r", install]) + if (!installed.includes("ready")) throw new NativeContainmentUnavailableError(`Contained WordPress installation did not complete: ${this.redactFixtureSecret(installed)}`) + } + private redactFixtureSecret(value: string): string { + return value.split(this.fixturePassword).join("[redacted]") } private async measureColdStartup(): Promise { if (this.startupMs <= 0) throw new NativeContainmentUnavailableError("Contained WordPress startup was not measured.") diff --git a/tests/native-runtime.test.ts b/tests/native-runtime.test.ts index 80855effa..96d556f24 100644 --- a/tests/native-runtime.test.ts +++ b/tests/native-runtime.test.ts @@ -80,7 +80,8 @@ const docker = createDockerNativeRuntimeDriver({ if (args[0] === "inspect") return { stdout: "running\n0\n{\"80/tcp\":[{\"HostIp\":\"127.0.0.1\",\"HostPort\":\"49152\"}]}\n", stderr: "" } if (args.some((arg) => arg.includes("PHP_VERSION"))) return { stdout: "8.4.1\napache2handler", stderr: "" } if (args.some((arg) => arg.includes("opcache_get_configuration()"))) return { stdout: "{\"directives\":{\"opcache.enable\":true}}", stderr: "" } - if (args.some((arg) => arg.includes("get_user_by"))) return { stdout: "ready", stderr: "" } + if (args.some((arg) => arg.includes("check_connection"))) return { stdout: "ready", stderr: "" } + if (args.some((arg) => arg.includes("wp_install("))) return { stdout: "ready", stderr: "" } return { stdout: "ok", stderr: "" } }, }) From 3e41c3324c2d2481aedbfcc6c03ce0c46a5c969b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 15:07:19 -0400 Subject: [PATCH 7/9] fix(runtime): pin native site url to preview origin --- packages/runtime-native/src/docker-native-runtime.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/runtime-native/src/docker-native-runtime.ts b/packages/runtime-native/src/docker-native-runtime.ts index b90e844d0..2b0d2b92e 100644 --- a/packages/runtime-native/src/docker-native-runtime.ts +++ b/packages/runtime-native/src/docker-native-runtime.ts @@ -227,8 +227,10 @@ class DockerNativeRuntimeDriver implements NativeRuntimeDriver { /// so installation cannot silently depend on markup or locale. private async installFixtureWordPress(): Promise { await this.waitForDatabase() - const install = "define('WP_INSTALLING', true); require '/var/www/html/wp-load.php'; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; if (!is_blog_installed()) { $result = wp_install('WP Codebox Fixture', 'fixture-admin', 'fixture-admin@example.test', false, '', getenv('WP_CODEBOX_FIXTURE_PASSWORD')); if (is_wp_error($result)) { echo 'install-error: ', $result->get_error_message(); exit(1); } } echo get_user_by('login', 'fixture-admin') && is_blog_installed() ? 'ready' : 'missing';" - const installed = await this.diagnosticDocker(["exec", "-e", `WP_CODEBOX_FIXTURE_PASSWORD=${this.fixturePassword}`, this.app, "php", "-r", install]) + // A CLI install guesses `http://localhost`, which no browser can reach. + // Pin both URLs to the published preview origin so redirects resolve. + const install = "define('WP_INSTALLING', true); require '/var/www/html/wp-load.php'; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; if (!is_blog_installed()) { $result = wp_install('WP Codebox Fixture', 'fixture-admin', 'fixture-admin@example.test', false, '', getenv('WP_CODEBOX_FIXTURE_PASSWORD')); if (is_wp_error($result)) { echo 'install-error: ', $result->get_error_message(); exit(1); } } update_option('siteurl', getenv('WP_CODEBOX_SITE_URL')); update_option('home', getenv('WP_CODEBOX_SITE_URL')); echo get_user_by('login', 'fixture-admin') && is_blog_installed() && get_option('home') === getenv('WP_CODEBOX_SITE_URL') ? 'ready' : 'missing';" + const installed = await this.diagnosticDocker(["exec", "-e", `WP_CODEBOX_FIXTURE_PASSWORD=${this.fixturePassword}`, "-e", `WP_CODEBOX_SITE_URL=${this.previewUrl}`, this.app, "php", "-r", install]) if (!installed.includes("ready")) throw new NativeContainmentUnavailableError(`Contained WordPress installation did not complete: ${this.redactFixtureSecret(installed)}`) } private redactFixtureSecret(value: string): string { From 563f7d521ddad0db9b61862cafb661914f40e7f7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 15:09:54 -0400 Subject: [PATCH 8/9] test(runtime): resolve native evidence directory --- tests/native-docker-runtime.integration.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/native-docker-runtime.integration.test.ts b/tests/native-docker-runtime.integration.test.ts index f3592a0b4..d9f3f84e4 100644 --- a/tests/native-docker-runtime.integration.test.ts +++ b/tests/native-docker-runtime.integration.test.ts @@ -1,15 +1,19 @@ import assert from "node:assert/strict" import { spawnSync } from "node:child_process" -import { mkdtemp, readFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile } from "node:fs/promises" import { tmpdir } from "node:os" -import { join } from "node:path" +import { join, resolve } from "node:path" import { createDockerNativeRuntimeDriver } from "@automattic/wp-codebox-native" const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8", timeout: 10_000 }) if (docker.status !== 0) { console.log("native Docker runtime integration skipped: trusted-containment-tools-unavailable") } else { - const artifactsDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-native-docker-")) + // Reviewers need a resolvable path. An explicit directory keeps evidence out + // of a per-job temporary root that its scheduler may reclaim. + const requested = process.env.WP_CODEBOX_NATIVE_ARTIFACTS_DIR + const artifactsDirectory = requested ? resolve(requested) : await mkdtemp(join(tmpdir(), "wp-codebox-native-docker-")) + if (requested) await mkdir(artifactsDirectory, { recursive: true }) const driver = createDockerNativeRuntimeDriver() try { const provenance = await driver.create({ backend: "wordpress-native", environment: { kind: "wordpress", name: "WordPress", version: "6.8", phpVersion: "8.4" }, policy: { network: "deny", filesystem: "sandbox", commands: ["wordpress.run-php", "wordpress.browser-actions", "wordpress.bench"], secrets: "none", approvals: "never" }, artifactsDirectory }) From 99ba0ff5ac74616e70f224821cce488bddd52cb7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 15:23:22 -0400 Subject: [PATCH 9/9] test(runtime): run native docker lane with chromium --- scripts/smoke-discovery.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/smoke-discovery.ts b/scripts/smoke-discovery.ts index cbf990862..7d70b2854 100644 --- a/scripts/smoke-discovery.ts +++ b/scripts/smoke-discovery.ts @@ -55,6 +55,7 @@ const BROWSER_FILES: readonly string[] = [ "tests/browser-visual-compare-url-capture.test.ts", "tests/browser-viewport-replay.test.ts", "tests/editor-actions-save.integration.test.ts", + "tests/native-docker-runtime.integration.test.ts", "tests/playground-mapped-domain-multisite.integration.test.ts", "tests/playground-staged-upload-preview.integration.test.ts", "tests/runtime-backed-multisite-workload.integration.test.ts",