diff --git a/package.json b/package.json index 29d7edff..a1b2d852 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,7 @@ "test:wordpress-hotspots-contracts": "tsx tests/wordpress-hotspots-contracts.test.ts", "test:cache-churn-observation-contracts": "tsx tests/cache-churn-observation-contracts.test.ts", "test:wordpress-db-contracts": "tsx tests/wordpress-db-contracts.test.ts", + "test:wp-cli-temporary-script": "tsx tests/wp-cli-temporary-script.test.ts", "test:browser-sdk-facade": "tsx tests/browser-sdk-facade.test.ts", "check": "npm run test:production-boundary-enforcement && npm run smoke -- --group=check" }, diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index f0598ad5..5c75ec4d 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -14,7 +14,7 @@ import { browserWordPressDiagnosticProvider, isBrowserCommandArtifactError, runB import type { PluginCheckArtifact, ThemeCheckArtifact } from "./check-artifacts.js" import { executePlaygroundCommand } from "./command-router.js" import { firstCommandWordPressAdminAuthRequirement } from "./command-auth-requirements.js" -import { argValue, cleanWpCliOutput, shellArgv, wordpressBlockExerciseInputFromArgs, wordpressBlockExercisePhpCode, wordpressCrudOperationFromArgs, wordpressCrudOperationPhpCode, wordpressDbOperationFromArgs, wordpressDbOperationPhpCode, wpCliCommandFromArgs, wpCliPhpScript } from "./commands.js" +import { argValue, cleanWpCliOutput, runWithTemporaryWpCliScript, shellArgv, wordpressBlockExerciseInputFromArgs, wordpressBlockExercisePhpCode, wordpressCrudOperationFromArgs, wordpressCrudOperationPhpCode, wordpressDbOperationFromArgs, wordpressDbOperationPhpCode, wpCliCommandFromArgs } from "./commands.js" import { bootstrapPhpCode } from "./php-bootstrap.js" import { observeHttpResponse as observeHttpResponseArtifact, observeWordPressState as observeWordPressStateArtifact } from "./observation-artifacts.js" import { PlaygroundCommandCrashError, assertPlaygroundResponseOk, errorMessage, type PlaygroundRunResponse } from "./playground-command-errors.js" @@ -1054,13 +1054,7 @@ class PlaygroundRuntime implements Runtime { throw new Error("wordpress.wp-cli requires a non-empty command") } - if (!server.playground.writeFile) { - throw new Error("wordpress.wp-cli requires a Playground backend with writeFile support") - } - - const scriptPath = `/tmp/wp-codebox-wp-cli-${this.commands.length}.php` - await server.playground.writeFile(scriptPath, wpCliPhpScript(argv)) - const response = await this.runPlaygroundCommand("wordpress.wp-cli", server, { scriptPath }) + const response = await this.runWpCliCommand(server, argv) assertPlaygroundResponseOk("wordpress.wp-cli", response) return cleanWpCliOutput(response.text) @@ -1471,13 +1465,18 @@ class PlaygroundRuntime implements Runtime { } private async runWpCliCommand(server: PlaygroundCliServer, argv: string[]): Promise { - if (!server.playground.writeFile) { - throw new Error("WP-CLI commands require a Playground backend with writeFile support") + if (!server.playground.writeFile || !server.playground.unlink) { + throw new Error("WP-CLI commands require a Playground backend with writeFile and unlink support") } - - const scriptPath = `/tmp/wp-codebox-wp-cli-${this.commands.length}-${Date.now().toString(36)}.php` - await server.playground.writeFile(scriptPath, wpCliPhpScript(argv)) - return this.runPlaygroundCommand("wordpress.wp-cli", server, { scriptPath }) + return runWithTemporaryWpCliScript( + { + writeFile: (path, contents) => server.playground.writeFile!(path, contents), + unlink: (path) => server.playground.unlink!(path), + }, + this.runtimeId, + argv, + (scriptPath) => this.runPlaygroundCommand("wordpress.wp-cli", server, { scriptPath }), + ) } private async createRuntimeWpCliBridge(server: PlaygroundCliServer): Promise { @@ -1758,13 +1757,7 @@ class PlaygroundRuntime implements Runtime { } private async runWpCliArgv(server: PlaygroundCliServer, argv: string[]): Promise { - if (!server.playground.writeFile) { - throw new Error("WP-CLI commands require a Playground backend with writeFile support") - } - - const scriptPath = `/tmp/wp-codebox-wp-cli-${this.commands.length}-${Date.now().toString(36)}.php` - await server.playground.writeFile(scriptPath, wpCliPhpScript(argv)) - return this.runPlaygroundCommand("wordpress.wp-cli", server, { scriptPath }) + return this.runWpCliCommand(server, argv) } private async runPlaygroundCommand(command: string, server: PlaygroundCliServer, options: { code: string } | { scriptPath: string }): Promise { diff --git a/packages/runtime-playground/src/preview-server.ts b/packages/runtime-playground/src/preview-server.ts index 79e3b454..1f0ba238 100644 --- a/packages/runtime-playground/src/preview-server.ts +++ b/packages/runtime-playground/src/preview-server.ts @@ -13,6 +13,7 @@ export interface PlaygroundCliServer { run(options: { code: string } | { scriptPath: string }): Promise onMessage?(listener: (data: string) => Promise | string | void): Promise<(() => Promise | void) | void> | (() => Promise | void) | void readFileAsText?(path: string): string | Promise + unlink?(path: string): Promise | void writeFile?(path: string, contents: string): Promise } serverUrl: string diff --git a/packages/runtime-playground/src/programmatic-playground-runner.ts b/packages/runtime-playground/src/programmatic-playground-runner.ts index 0c5a80a5..9f319cd7 100644 --- a/packages/runtime-playground/src/programmatic-playground-runner.ts +++ b/packages/runtime-playground/src/programmatic-playground-runner.ts @@ -23,6 +23,7 @@ interface ProgrammaticPHP { onMessage(listener: (data: string) => Promise | string | void): () => Promise readFileAsText(path: string): string run(options: { code: string } | { scriptPath: string }): Promise + unlink(path: string): void writeFile(path: string, contents: string): void } @@ -91,6 +92,9 @@ export async function startProgrammaticPlaygroundServer(spec: RuntimeCreateSpec, run: (runOptions) => runPhp(primaryPhp, runOptions), onMessage: (listener) => primaryPhp.onMessage(listener), readFileAsText: (path) => primaryPhp.readFileAsText(path), + unlink(path) { + primaryPhp.unlink(path) + }, async writeFile(path, contents) { primaryPhp.writeFile(path, contents) }, diff --git a/packages/runtime-playground/src/wp-cli-command-handlers.ts b/packages/runtime-playground/src/wp-cli-command-handlers.ts index e2b33771..d3e7e649 100644 --- a/packages/runtime-playground/src/wp-cli-command-handlers.ts +++ b/packages/runtime-playground/src/wp-cli-command-handlers.ts @@ -1,6 +1,12 @@ +import { randomBytes } from "node:crypto" import { argValue } from "./command-args.js" import { phpCliStreamConstants } from "./php-snippets.js" +interface WpCliTemporaryScriptFilesystem { + writeFile(path: string, contents: string): Promise + unlink(path: string): Promise | void +} + export function wpCliCommandFromArgs(args: string[]): string { const explicit = argValue(args, "command") if (explicit) { @@ -58,6 +64,17 @@ require '/tmp/wp-cli.phar'; ` } +export async function runWithTemporaryWpCliScript(filesystem: WpCliTemporaryScriptFilesystem, runtimeId: string, argv: string[], run: (scriptPath: string) => Promise): Promise { + const runtimeNamespace = runtimeId.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 80) || "runtime" + const scriptPath = `/tmp/wp-codebox-wp-cli-${runtimeNamespace}-${randomBytes(16).toString("hex")}.php` + await filesystem.writeFile(scriptPath, wpCliPhpScript(argv)) + try { + return await run(scriptPath) + } finally { + await Promise.resolve(filesystem.unlink(scriptPath)).catch(() => undefined) + } +} + export function cleanWpCliOutput(output: string): string { return output.replace(/^#!\/usr\/bin\/env php\r?\n/, "") } diff --git a/scripts/smoke-manifest.ts b/scripts/smoke-manifest.ts index b6c4088c..bcc0182f 100644 --- a/scripts/smoke-manifest.ts +++ b/scripts/smoke-manifest.ts @@ -120,6 +120,7 @@ export const smokeGroups = { tsxSmoke("replay-export-snapshot-scoping-smoke"), tsxSmoke("runtime-overlay-validation-smoke"), npmScript("test:runtime-php-snippets"), + npmScript("test:wp-cli-temporary-script"), npmScript("test:php-runtime-provider-registry"), tsxSmoke("composer-backed-source-hydration-smoke"), tsxSmoke("composer-package-overlay-autoload-layout-smoke"), diff --git a/tests/wp-cli-temporary-script.test.ts b/tests/wp-cli-temporary-script.test.ts new file mode 100644 index 00000000..730f2e91 --- /dev/null +++ b/tests/wp-cli-temporary-script.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict" +import { runWithTemporaryWpCliScript, shellArgv } from "../packages/runtime-playground/dist/wp-cli-command-handlers.js" + +class MemoryFilesystem { + readonly files = new Map() + readonly writtenPaths: string[] = [] + readonly unlinkedPaths: string[] = [] + + async writeFile(path: string, contents: string): Promise { + if (this.files.has(path)) { + throw new Error(`Could not write to ${JSON.stringify(path)}: File exists.`) + } + this.files.set(path, contents) + this.writtenPaths.push(path) + } + + unlink(path: string): void { + this.files.delete(path) + this.unlinkedPaths.push(path) + } +} + +const oldPaths = Array.from({ length: 16 }, () => "/tmp/wp-codebox-wp-cli-2.php") +assert.equal(new Set(oldPaths).size, 1, "the old command-count suffix reproduces the shared wrapper path") + +const filesystem = new MemoryFilesystem() +let invocationCount = 0 +for (const { concurrency, iterations, repetitions } of [ + { concurrency: 2, iterations: 128, repetitions: 3 }, + { concurrency: 6, iterations: 256, repetitions: 3 }, + { concurrency: 16, iterations: 512, repetitions: 2 }, + { concurrency: 64, iterations: 1_024, repetitions: 1 }, +]) { + for (let repetition = 0; repetition < repetitions; repetition++) { + const markers = Array.from({ length: iterations }, (_, index) => `marker-${concurrency}-${repetition}-${index.toString().padStart(6, "0")}`) + const outputs = await runBounded(markers, concurrency, async (marker, index) => { + invocationCount++ + return runWithTemporaryWpCliScript( + filesystem, + "runtime/shared/../../unsafe", + ["eval", `echo ${JSON.stringify(marker)};`], + async (scriptPath) => { + await new Promise((resolve) => setTimeout(resolve, index % 5)) + const script = filesystem.files.get(scriptPath) + assert.ok(script, `wrapper must exist while ${marker} executes`) + assert.match(scriptPath, /^\/tmp\/wp-codebox-wp-cli-runtime-shared-------unsafe-[a-f0-9]{32}\.php$/) + assert.ok(script.includes(marker), `${marker} must execute only its own payload`) + const otherMarker = markers[(index + 1) % markers.length] + assert.equal(script.includes(otherMarker), false, `${marker} must not execute ${otherMarker}`) + return marker + }, + ) + }) + assert.deepEqual(outputs, markers) + assert.equal(filesystem.files.size, 0, `concurrency ${concurrency} must return the temp directory to baseline`) + } +} + +assert.equal(new Set(filesystem.writtenPaths).size, invocationCount) +assert.deepEqual(new Set(filesystem.unlinkedPaths), new Set(filesystem.writtenPaths)) +assert.throws(() => shellArgv("eval 'unterminated"), /Unclosed quote/, "malformed WP-CLI input must fail before allocating a wrapper") + +const nonzero = await runWithTemporaryWpCliScript(filesystem, "runtime-nonzero", ["eval", "exit(7)"], async () => ({ exitCode: 7, text: "", errors: "intentional" })) +assert.deepEqual(nonzero, { exitCode: 7, text: "", errors: "intentional" }, "structured nonzero responses must remain compatible") +assert.equal(filesystem.files.size, 0, "structured nonzero responses must clean up their wrapper") + +for (const failure of [ + new Error("wrapper execution failure"), + new Error("runtime command exceeded timeout"), + new Error("runtime execution was aborted"), + new Error("adapter exception"), +]) { + await assert.rejects( + runWithTemporaryWpCliScript(filesystem, "runtime-failures", ["eval", "broken"], async () => { throw failure }), + failure, + ) + assert.equal(filesystem.files.size, 0, `${failure.message} must clean up its owned wrapper`) +} + +let unlinkedAfterRejectedWrite = false +await assert.rejects( + runWithTemporaryWpCliScript({ + async writeFile() { + throw new Error("File exists") + }, + unlink() { + unlinkedAfterRejectedWrite = true + }, + }, "runtime-write-failure", ["version"], async () => "unreachable"), + /File exists/, +) +assert.equal(unlinkedAfterRejectedWrite, false, "a rejected write must not claim or remove another invocation's file") + +console.log(`WP-CLI temporary wrapper stress ok: ${invocationCount} isolated invocations at concurrency 2, 6, 16, and 64`) + +async function runBounded(items: T[], concurrency: number, operation: (item: T, index: number) => Promise): Promise { + const outputs = new Array(items.length) + let nextIndex = 0 + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (nextIndex < items.length) { + const index = nextIndex++ + outputs[index] = await operation(items[index], index) + } + })) + return outputs +}