From 0416e0813ca8bc11b70c442636aa2cf11ac92fef Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 18:27:38 -0400 Subject: [PATCH 1/3] Fix readonly Playground mount isolation --- package.json | 1 + .../src/mount-materialization.ts | 45 ++++++++++++- .../src/playground-cli-runner.ts | 20 +++++- tests/playground-readonly-mounts.test.ts | 64 +++++++++++++++++++ 4 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 tests/playground-readonly-mounts.test.ts diff --git a/package.json b/package.json index a528c540..a870d923 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "test:external-native-package-materialization": "tsx tests/external-native-package-materialization.test.ts", "test:runtime-sources-materialization": "tsx tests/runtime-sources-materialization.test.ts", "test:runtime-sources-playground-integration": "tsx tests/runtime-sources-playground-integration.test.ts", + "test:playground-readonly-mounts": "tsx tests/playground-readonly-mounts.test.ts", "test:browser-task-builder": "tsx tests/browser-task-builder.test.ts", "test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts", "test:browser-runtime-file-ops": "tsx tests/browser-runtime-file-ops.test.ts", diff --git a/packages/runtime-playground/src/mount-materialization.ts b/packages/runtime-playground/src/mount-materialization.ts index 9dc2e45f..67355ca5 100644 --- a/packages/runtime-playground/src/mount-materialization.ts +++ b/packages/runtime-playground/src/mount-materialization.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto" -import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises" -import { dirname, isAbsolute, join, relative, resolve } from "node:path" +import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" import { materializationPhaseResult, namedFileTreeSkipPolicyNames, phpStringArrayLiteral, type MaterializationPhaseResult, type MountSpec } from "@automattic/wp-codebox-core" import type { PlaygroundCliServer } from "./preview-server.js" import { SKIPPED_CAPTURE_DIRECTORIES } from "./artifacts.js" @@ -32,6 +33,11 @@ export interface MountMaterializationResult { export type StagedInputMaterializationResult = MountMaterializationResult +export interface ReadonlyMountStaging { + mounts: MountSpec[] + [Symbol.asyncDispose](): Promise +} + interface HostMountFilePayload { target: string contentsBase64: string @@ -56,6 +62,41 @@ interface HostMountFileMaterializationResponse { const HOST_MOUNT_FILE_BATCH_SIZE = 100 const HOST_MOUNT_DIRECTORY_BATCH_SIZE = 500 +/** + * Playground's Node filesystem mount handler is writable. Snapshot readonly + * inputs into a runtime-owned directory before handing them to that handler. + */ +export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promise { + const readonlyMounts = mounts.filter((mount) => mount.mode === "readonly") + if (readonlyMounts.length === 0) { + return { + mounts, + async [Symbol.asyncDispose]() {}, + } + } + + const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-")) + try { + const stagedMounts = await Promise.all(mounts.map(async (mount, index) => { + if (mount.mode !== "readonly") { + return mount + } + const source = join(root, `${index}-${basename(mount.source) || "mount"}`) + await cp(mount.source, source, { recursive: mount.type !== "file", dereference: true }) + return { ...mount, source } + })) + return { + mounts: stagedMounts, + async [Symbol.asyncDispose]() { + await rm(root, { recursive: true, force: true }) + }, + } + } catch (error) { + await rm(root, { recursive: true, force: true }) + throw error + } +} + function mountMaterializationResult(input: Omit): MountMaterializationResult { return { ...input, diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 7683778f..f4f3bbe9 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -13,6 +13,7 @@ import { createServer as createNetServer } from "node:net" import * as PlaygroundStorage from "@wp-playground/storage" import { resolveWordPressRelease } from "@wp-playground/wordpress" import { phpEnvAssignments, phpWpConfigDefineAssignments } from "./php-snippets.js" +import { stageReadonlyPlaygroundMounts, type ReadonlyMountStaging } from "./mount-materialization.js" const DEFAULT_RUNTIME_PHP_INI_ENTRIES = { memory_limit: "512M" } @@ -62,6 +63,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: mounts: mounts.length, }) emitProgress("preview:loading-client", "running", "Loading preview") + let readonlyMountStaging: ReadonlyMountStaging | undefined try { if (spec.preview?.port) { await assertPreviewPortAvailable(spec.preview.port) @@ -74,6 +76,8 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: const wordpressInstallMode = spec.environment.wordpressInstallMode ?? "install-from-existing-files" const bootstrapIniEntries = runtimeBootstrapPhpIniEntries(spec) const useProgrammaticRunner = shouldUseProgrammaticPlaygroundRunner(spec, options) + readonlyMountStaging = await stageReadonlyPlaygroundMounts(mounts) + const stagedMounts = readonlyMountStaging.mounts const wordpressStartupAsset = wordpressDirectory ? undefined : await resolvePlaygroundWordPressStartupAsset(spec.environment.version, spec.environment.assets?.wordpressZip) const cacheValidation = wordpressStartupAsset?.cacheValidation ?? { version: spec.environment.version ?? "mounted-wordpress-source", @@ -99,7 +103,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: ...spec.preview, port, }, - }, mounts, { + }, stagedMounts, { bootstrapIniEntries: bootstrapIniEntries!, phpIniEntries: pluginRuntimePhpIniEntries(spec), wordpressDirectory: wordpressDirectory!, @@ -119,7 +123,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: skipBrowser: true, workers: 6, mount: [ - ...mounts.map((mount) => ({ + ...stagedMounts.map((mount) => ({ hostPath: mount.source, vfsPath: mount.target, })), @@ -154,12 +158,22 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: upstreamUrl: server.serverUrl, lease: proxiedServer.previewLease ? previewLeaseDetail(proxiedServer.previewLease) : undefined, }) - return proxiedServer + return { + ...proxiedServer, + async [Symbol.asyncDispose]() { + try { + await proxiedServer[Symbol.asyncDispose]() + } finally { + await readonlyMountStaging?.[Symbol.asyncDispose]() + } + }, + } } catch (error) { emitProgress("preview:error", "failed", "Preview failed to start", { error: errorDetail(error), }) + await readonlyMountStaging?.[Symbol.asyncDispose]() if (spec.preview?.port && errorHasCode(error, "EADDRINUSE")) { throw new PlaygroundPreviewPortUnavailableError(spec.preview.port, error) } diff --git a/tests/playground-readonly-mounts.test.ts b/tests/playground-readonly-mounts.test.ts new file mode 100644 index 00000000..d1ce911e --- /dev/null +++ b/tests/playground-readonly-mounts.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict" +import { createHash } from "node:crypto" +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { startPlaygroundCliServer, type PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.js" +import type { RuntimeCreateSpec } from "../packages/runtime-core/src/index.js" + +const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-")) +const readonlySource = join(root, "readonly.bin") +const readwriteSource = join(root, "readwrite.bin") +const readonlyBytes = Buffer.from([0, 255, 1, 2, 3, 127, 128]) +const readwriteBytes = Buffer.from([10, 20, 30]) +await writeFile(readonlySource, readonlyBytes) +await writeFile(readwriteSource, readwriteBytes) + +const spec: RuntimeCreateSpec = { + backend: "wordpress-playground", + environment: { version: "6.8", phpVersion: "8.4", blueprint: {} }, + policy: { network: "deny", filesystem: "readwrite-mounts", commands: ["wordpress.run-php"], secrets: "none", approvals: "never" }, +} + +let mountedReadonlyPath = "" +const cliModule: PlaygroundCliModule = { + async runCLI(options) { + const readonlyMount = options.mount.find((mount) => mount.vfsPath === "/readonly") + const readwriteMount = options.mount.find((mount) => mount.vfsPath === "/readwrite") + assert.ok(readonlyMount) + assert.ok(readwriteMount) + mountedReadonlyPath = readonlyMount.hostPath + // This is the host path Playground's writable Node mount handler receives. + await writeFile(readonlyMount.hostPath, Buffer.from("sandbox overwrite")) + await writeFile(readwriteMount.hostPath, Buffer.from("sandbox overwrite")) + return { + serverUrl: "http://127.0.0.1:65535", + playground: { async run() { return { text: "" } } }, + async [Symbol.asyncDispose]() {}, + } + }, +} + +try { + const beforeReadonlyHash = sha256(await readFile(readonlySource)) + const server = await startPlaygroundCliServer(spec, [ + { type: "file", source: readonlySource, target: "/readonly", mode: "readonly" }, + { type: "file", source: readwriteSource, target: "/readwrite", mode: "readwrite" }, + ], { cliModule }) + + assert.equal(sha256(await readFile(readonlySource)), beforeReadonlyHash, "readonly source bytes must survive a sandbox overwrite") + assert.deepEqual(await readFile(readwriteSource), Buffer.from("sandbox overwrite"), "readwrite mounts must retain host-write behavior") + assert.notEqual(mountedReadonlyPath, readonlySource, "readonly mounts must use a private staged path") + + await server[Symbol.asyncDispose]() + await assert.rejects(access(mountedReadonlyPath), /ENOENT/, "readonly mount staging must be removed with the runtime") +} finally { + await rm(root, { recursive: true, force: true }) +} + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex") +} + +console.log("playground readonly mount isolation ok") From e9e0f39d0fb6f24571d09d20b2e624f473960b6a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 18:40:06 -0400 Subject: [PATCH 2/3] Add readonly Playground mount integration test --- package.json | 1 + ...ground-readonly-mounts-integration.test.ts | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/playground-readonly-mounts-integration.test.ts diff --git a/package.json b/package.json index a870d923..6de91246 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "test:runtime-sources-materialization": "tsx tests/runtime-sources-materialization.test.ts", "test:runtime-sources-playground-integration": "tsx tests/runtime-sources-playground-integration.test.ts", "test:playground-readonly-mounts": "tsx tests/playground-readonly-mounts.test.ts", + "test:playground-readonly-mounts-integration": "tsx tests/playground-readonly-mounts-integration.test.ts", "test:browser-task-builder": "tsx tests/browser-task-builder.test.ts", "test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts", "test:browser-runtime-file-ops": "tsx tests/browser-runtime-file-ops.test.ts", diff --git a/tests/playground-readonly-mounts-integration.test.ts b/tests/playground-readonly-mounts-integration.test.ts new file mode 100644 index 00000000..64a9755c --- /dev/null +++ b/tests/playground-readonly-mounts-integration.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { createHash } from "node:crypto" +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" + +const execFileAsync = promisify(execFile) +const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-integration-")) +const readonlySource = join(root, "readonly.bin") +const readwriteSource = join(root, "readwrite.bin") +const recipePath = join(root, "recipe.json") +const artifactsPath = join(root, "artifacts") +const readonlyBytes = Buffer.from([0, 255, 1, 2, 3, 127, 128]) +const overwrittenBytes = Buffer.from([128, 127, 3, 2, 1, 255, 0]) +const stagingDirectoriesBefore = await readonlyStagingDirectories() + +try { + await writeFile(readonlySource, readonlyBytes) + await writeFile(readwriteSource, readonlyBytes) + await writeFile(recipePath, `${JSON.stringify({ + schema: "wp-codebox/workspace-recipe/v1", + runtime: { backend: "wordpress-playground", wp: "6.5", blueprint: { steps: [] } }, + inputs: { + mounts: [ + { source: readonlySource, target: "/wordpress/readonly.bin", mode: "readonly" }, + { source: readwriteSource, target: "/wordpress/readwrite.bin", mode: "readwrite" }, + ], + }, + workflow: { + steps: [{ + command: "wordpress.run-php", + args: [`code=$contents = base64_decode('${overwrittenBytes.toString("base64")}'); file_put_contents('/wordpress/readonly.bin', $contents); file_put_contents('/wordpress/readwrite.bin', $contents);`], + }], + }, + })}\n`) + + const result = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--artifacts", artifactsPath, "--json"], { + cwd: process.cwd(), + timeout: 300_000, + maxBuffer: 2 * 1024 * 1024, + }) + const output = JSON.parse(result.stdout) + assert.equal(output.success, true, JSON.stringify(output)) + assert.equal(sha256(await readFile(readonlySource)), sha256(readonlyBytes), "readonly host bytes must survive an actual Playground PHP overwrite") + assert.deepEqual(await readFile(readwriteSource), overwrittenBytes, "readwrite host bytes must reflect an actual Playground PHP overwrite") + assert.deepEqual(await readonlyStagingDirectories(), stagingDirectoriesBefore, "recipe-run cleanup must remove readonly mount staging") +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (/fetch failed|Could not resolve host|Connection timed out|network is unreachable/i.test(message)) { + console.log(`playground readonly mount integration skipped: WordPress runtime source was unreachable (${message})`) + } else { + throw error + } +} finally { + await rm(root, { recursive: true, force: true }) +} + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex") +} + +async function readonlyStagingDirectories(): Promise { + return (await readdir(tmpdir())).filter((entry) => entry.startsWith("wp-codebox-readonly-mounts-")).sort() +} + +console.log("playground readonly mount integration ok") From 84276439afc24b313f3fa77cbc830c3c697fef5d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 18:44:48 -0400 Subject: [PATCH 3/3] Narrow readonly mount integration skip --- ...ground-readonly-mounts-integration.test.ts | 87 +++++++++++++++---- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/tests/playground-readonly-mounts-integration.test.ts b/tests/playground-readonly-mounts-integration.test.ts index 64a9755c..5dffdae0 100644 --- a/tests/playground-readonly-mounts-integration.test.ts +++ b/tests/playground-readonly-mounts-integration.test.ts @@ -36,27 +36,33 @@ try { }, })}\n`) - const result = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--artifacts", artifactsPath, "--json"], { - cwd: process.cwd(), - timeout: 300_000, - maxBuffer: 2 * 1024 * 1024, - }) - const output = JSON.parse(result.stdout) - assert.equal(output.success, true, JSON.stringify(output)) - assert.equal(sha256(await readFile(readonlySource)), sha256(readonlyBytes), "readonly host bytes must survive an actual Playground PHP overwrite") - assert.deepEqual(await readFile(readwriteSource), overwrittenBytes, "readwrite host bytes must reflect an actual Playground PHP overwrite") - assert.deepEqual(await readonlyStagingDirectories(), stagingDirectoriesBefore, "recipe-run cleanup must remove readonly mount staging") -} catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (/fetch failed|Could not resolve host|Connection timed out|network is unreachable/i.test(message)) { - console.log(`playground readonly mount integration skipped: WordPress runtime source was unreachable (${message})`) - } else { - throw error + const output = await runRecipe() + if (output) { + assert.equal(output.success, true, JSON.stringify(output)) + assert.equal(sha256(await readFile(readonlySource)), sha256(readonlyBytes), "readonly host bytes must survive an actual Playground PHP overwrite") + assert.deepEqual(await readFile(readwriteSource), overwrittenBytes, "readwrite host bytes must reflect an actual Playground PHP overwrite") + assert.deepEqual(await readonlyStagingDirectories(), stagingDirectoriesBefore, "recipe-run cleanup must remove readonly mount staging") } } finally { await rm(root, { recursive: true, force: true }) } +assert.equal(isUnavailableWordPressRuntimeSource({ + stdout: JSON.stringify({ + schema: "wp-codebox/recipe-run/v1", + success: false, + phaseEvidence: [{ name: "runtime_startup", status: "failed", error: { message: "Unable to resolve Playground startup asset wordpress-archive-cache for WordPress 6.5: fetch failed" } }], + }), +}), true, "only a structured pre-runtime WordPress archive acquisition failure may skip") + +assert.equal(isUnavailableWordPressRuntimeSource({ + stdout: JSON.stringify({ + schema: "wp-codebox/recipe-run/v1", + success: false, + phaseEvidence: [{ name: "runtime_startup", status: "completed" }, { name: "run_workloads", status: "failed", error: { message: "fetch failed" } }], + }), +}), false, "a post-start failure containing fetch failed must fail the integration test") + function sha256(bytes: Buffer): string { return createHash("sha256").update(bytes).digest("hex") } @@ -65,4 +71,53 @@ async function readonlyStagingDirectories(): Promise { return (await readdir(tmpdir())).filter((entry) => entry.startsWith("wp-codebox-readonly-mounts-")).sort() } +async function runRecipe(): Promise { + try { + const result = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--artifacts", artifactsPath, "--json"], { + cwd: process.cwd(), + timeout: 300_000, + maxBuffer: 2 * 1024 * 1024, + }) + return recipeRunOutput(result.stdout) + } catch (error) { + if (isUnavailableWordPressRuntimeSource(error)) { + console.log("playground readonly mount integration skipped: WordPress runtime source was unavailable before runtime startup") + return undefined + } + throw error + } +} + +interface RecipeRunOutput { + schema?: string + success?: boolean + phaseEvidence?: Array<{ + name?: string + status?: string + error?: { message?: string } + }> +} + +function isUnavailableWordPressRuntimeSource(error: unknown): boolean { + const output = recipeRunOutput(error && typeof error === "object" && "stdout" in error ? error.stdout : undefined) + const startup = output?.phaseEvidence?.find((phase) => phase.name === "runtime_startup") + const message = startup?.error?.message ?? "" + return output?.schema === "wp-codebox/recipe-run/v1" + && startup?.status === "failed" + && /Unable to resolve Playground startup asset (wordpress-archive-cache|wordpress-release-metadata)/.test(message) + && /fetch failed|Could not resolve host|Connection timed out|network is unreachable/i.test(message) +} + +function recipeRunOutput(value: unknown): RecipeRunOutput | undefined { + if (typeof value !== "string") { + return undefined + } + try { + const output = JSON.parse(value) as RecipeRunOutput + return output && typeof output === "object" ? output : undefined + } catch { + return undefined + } +} + console.log("playground readonly mount integration ok")