Skip to content

Commit c41685e

Browse files
committed
Execute bounded PHP commands in clean Playground processes
1 parent 4e5874b commit c41685e

8 files changed

Lines changed: 35 additions & 110 deletions

packages/runtime-playground/src/php-bootstrap.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@ ${phpBody(code)}`
2121
}
2222

2323
export function bootstrapPhpCode(spec: RuntimeCreateSpec, code: string, args: string[], wpCliBridge?: PhpBootstrapBridge, failureDiagnosticFile?: string): string {
24+
const command = splitLeadingStrictTypesDeclare(code)
2425
if (argValue(args, "bootstrap") === "none") {
25-
return code
26+
return `<?php
27+
${command.strictTypesDeclare ? `${command.strictTypesDeclare}\n` : ""}${runtimeEnvPhp(spec, args)}
28+
${secretEnvPhp(spec)}
29+
${command.body}`
2630
}
2731

28-
const command = splitLeadingStrictTypesDeclare(code)
32+
const wordpressBootstrap = argValue(args, "bootstrap-mode") === "project" ? "" : "require_once '/wordpress/wp-load.php';"
2933

3034
const bootstrapped = `<?php
3135
${command.strictTypesDeclare ? `${command.strictTypesDeclare}\n` : ""}${phpFatalDiagnosticPhp()}
@@ -36,7 +40,7 @@ ${saveQueriesBootstrapPhp(args)}
3640
${runtimeEnvPhp(spec, args)}
3741
${secretEnvPhp(spec)}
3842
${componentManifestPhp(spec)}
39-
require_once '/wordpress/wp-load.php';
43+
${wordpressBootstrap}
4044
${failureDiagnosticFile ? phpFailureDiagnosticCompletionPhp() : ""}
4145
${recipeActivePluginBootstrapPhp(spec, args)}
4246
${wpCliBridge ? `putenv(${JSON.stringify(`WP_CODEBOX_TERMINAL_ACTION_URL=${wpCliBridge.url}`)});

packages/runtime-playground/src/phpunit-command-handlers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,11 @@ function pg_run_project_bootstrap_stage(array $cfg): void {
843843
$bootstrap = pg_project_bootstrap_from_config($phpunit_xml);
844844
$from_config = $bootstrap !== '';
845845
}
846+
if ($bootstrap === '') {
847+
pg_log('NOTICE:project bootstrap not declared; continuing without one');
848+
pg_stage_ok('project_bootstrap');
849+
return;
850+
}
846851
$bootstrap_real = pg_project_bootstrap_real_path($bootstrap, $phpunit_xml, $from_config);
847852
if ($bootstrap_real === null) {
848853
throw new RuntimeException('project bootstrap not found; pass project-bootstrap=<relative path> or declare phpunit bootstrap');

packages/runtime-playground/src/playground-cli-runner.ts

Lines changed: 6 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { PlaygroundCliExitError, type PlaygroundCliBufferedOutput } from "./play
33
import { PlaygroundPreviewPortUnavailableError, assertPreviewPortAvailable, errorHasCode, withPreviewProxy, type PlaygroundCliServer } from "./preview-server.js"
44
import { startProgrammaticPlaygroundServer } from "./programmatic-playground-runner.js"
55
import { normalizeLiveProgressEvent, previewLease, type BrowserStartupProgressEvent, type BrowserStartupProgressPhase, type BrowserStartupProgressStatus, type MountSpec, type PreviewLease, type RuntimeCreateSpec, type RuntimePreviewLeaseProvider } from "@automattic/wp-codebox-core"
6-
import { randomBytes, randomInt } from "node:crypto"
6+
import { randomInt } from "node:crypto"
77
import { existsSync } from "node:fs"
88
import { createServer as createHttpServer, type Server as HttpServer } from "node:http"
99
import { mkdir, readFile, rename, rm, stat, unlink, utimes, writeFile } from "node:fs/promises"
@@ -80,11 +80,6 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
8080
const wordpressInstallMode = spec.environment.wordpressInstallMode ?? "install-from-existing-files"
8181
const bootstrapIniEntries = runtimeBootstrapPhpIniEntries(spec)
8282
const useProgrammaticRunner = shouldUseProgrammaticPlaygroundRunner(spec, options)
83-
const requestWorkerEndpoint = useProgrammaticRunner ? undefined : {
84-
route: `/wp-codebox-execute-${randomBytes(12).toString("hex")}.php`,
85-
token: randomBytes(32).toString("base64url"),
86-
payloadDirectory: join(spec.artifactsDirectory ?? "artifacts", "playground-internal-shared"),
87-
}
8883
usesArchiveCache = !wordpressDirectory && !spec.environment.assets?.wordpressZip
8984
readonlyMountStaging = await stageReadonlyPlaygroundMounts(mounts)
9085
const stagedMounts = readonlyMountStaging.mounts
@@ -137,7 +132,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
137132
}, Boolean(spec.preview?.port)) : await startPlaygroundCliWithDynamicPortRetry(async (port) => {
138133
const { runCLI } = options.cliModule ?? (await import("@wp-playground/cli")) as unknown as PlaygroundCliModule
139134
const localAssetServer = wordpressStartupAsset?.localPath ? await serveLocalStartupAsset(wordpressStartupAsset.localPath) : undefined
140-
const bootstrapSharedMounts = await pluginRuntimeBootstrapSharedMounts(spec, requestWorkerEndpoint)
135+
const bootstrapSharedMounts = await pluginRuntimeBootstrapSharedMounts(spec)
141136
try {
142137
return await runCLI({
143138
command: "server",
@@ -179,7 +174,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
179174
fixedPreviewPort: spec.preview?.port ?? null,
180175
})
181176

182-
const proxiedServer = await withPreviewLeaseProvider(await withPreviewProxy({ ...server, ...(requestWorkerEndpoint ? { requestWorkerEndpoint } : {}) }, spec.preview?.port ?? 0, spec.preview?.bind), spec)
177+
const proxiedServer = await withPreviewLeaseProvider(await withPreviewProxy(server, spec.preview?.port ?? 0, spec.preview?.bind), spec)
183178
emitProgress("preview:ready", "complete", "Preview ready", {
184179
localUrl: proxiedServer.serverUrl,
185180
upstreamUrl: server.serverUrl,
@@ -303,9 +298,10 @@ export function shouldUseProgrammaticPlaygroundRunner(spec: RuntimeCreateSpec, o
303298
&& (Boolean(runtimeBootstrapPhpIniEntries(spec)) || Boolean(spec.environment.extensions?.length))
304299
}
305300

306-
async function pluginRuntimeBootstrapSharedMounts(spec: RuntimeCreateSpec, requestWorkerEndpoint?: { route: string; token: string; payloadDirectory: string }): Promise<Array<{ hostPath: string; vfsPath: string }>> {
301+
async function pluginRuntimeBootstrapSharedMounts(spec: RuntimeCreateSpec): Promise<Array<{ hostPath: string; vfsPath: string }>> {
307302
const iniEntries = runtimeBootstrapPhpIniEntries(spec)
308-
if (!iniEntries && !requestWorkerEndpoint) {
303+
const externalWpConfig = externalDatabaseWpConfig(spec)
304+
if (!iniEntries && !externalWpConfig) {
309305
return []
310306
}
311307

@@ -315,55 +311,17 @@ async function pluginRuntimeBootstrapSharedMounts(spec: RuntimeCreateSpec, reque
315311
await writeFile(join(directory, "php.ini"), phpIniContent(iniEntries), "utf8")
316312
await writeFile(join(directory, "wp-codebox-auto-prepend.php"), runtimeAutoPrependPhp(spec), "utf8")
317313
}
318-
if (requestWorkerEndpoint) await writeFile(join(directory, "request-worker.php"), requestWorkerPhp(requestWorkerEndpoint.token), "utf8")
319-
const externalWpConfig = externalDatabaseWpConfig(spec)
320314
if (externalWpConfig) await writeFile(join(directory, "wp-config.php"), externalWpConfig, "utf8")
321315

322316
return [
323317
...(iniEntries ? [
324318
{ hostPath: join(directory, "php.ini"), vfsPath: "/internal/shared/php.ini" },
325319
{ hostPath: join(directory, "wp-codebox-auto-prepend.php"), vfsPath: "/internal/shared/wp-codebox-auto-prepend.php" },
326320
] : []),
327-
...(requestWorkerEndpoint ? [
328-
{ hostPath: directory, vfsPath: "/internal/wp-codebox" },
329-
{ hostPath: join(directory, "request-worker.php"), vfsPath: `/wordpress${requestWorkerEndpoint.route}` },
330-
] : []),
331321
...(externalWpConfig ? [{ hostPath: join(directory, "wp-config.php"), vfsPath: "/wordpress/wp-config.php" }] : []),
332322
]
333323
}
334324

335-
function requestWorkerPhp(token: string): string {
336-
return `<?php
337-
if (!hash_equals(${phpLiteral(token)}, (string) ($_SERVER['HTTP_X_WP_CODEBOX_EXECUTION_TOKEN'] ?? ''))) {
338-
http_response_code(404);
339-
exit;
340-
}
341-
$wp_codebox_payload_id = (string) ($_SERVER['HTTP_X_WP_CODEBOX_EXECUTION_PAYLOAD'] ?? '');
342-
if (!preg_match('/^[a-f0-9]{32}$/', $wp_codebox_payload_id)) {
343-
http_response_code(400);
344-
exit;
345-
}
346-
$wp_codebox_payload = json_decode((string) file_get_contents('/internal/wp-codebox/execution-' . $wp_codebox_payload_id . '.json'), true);
347-
$wp_codebox_code = $wp_codebox_payload['code'] ?? null;
348-
$wp_codebox_environment = $wp_codebox_payload['environment'] ?? null;
349-
if (!is_string($wp_codebox_code) || !is_array($wp_codebox_environment)) {
350-
http_response_code(400);
351-
echo 'invalid execution payload';
352-
exit;
353-
}
354-
foreach ($wp_codebox_environment as $wp_codebox_name => $wp_codebox_value) {
355-
if (!is_string($wp_codebox_name) || !is_string($wp_codebox_value)) {
356-
http_response_code(400);
357-
exit;
358-
}
359-
putenv($wp_codebox_name . '=' . $wp_codebox_value);
360-
$_ENV[$wp_codebox_name] = $wp_codebox_value;
361-
$_SERVER[$wp_codebox_name] = $wp_codebox_value;
362-
}
363-
eval('?>' . $wp_codebox_code);
364-
`
365-
}
366-
367325
function runtimeBootstrapPhpIniEntries(spec: RuntimeCreateSpec): Record<string, string> | undefined {
368326
const entries = pluginRuntimeBootstrapPhpIniEntries(spec) ?? {}
369327
if (Object.keys(entries).length === 0 && !runtimeAutoPrependPhpBody(spec)) {

packages/runtime-playground/src/playground-runtime.ts

Lines changed: 8 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { randomBytes } from "node:crypto"
22
import { AsyncLocalStorage } from "node:async_hooks"
3-
import { mkdir, readFile, realpath, unlink, writeFile } from "node:fs/promises"
3+
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"
44
import type { IncomingMessage, ServerResponse } from "node:http"
55
import { dirname, join, resolve } from "node:path"
66
import { HostToolRegistry, PREVIEW_LEASE_SCHEMA, RUNTIME_EPISODE_OBSERVATION_SCHEMA, RUNTIME_EPISODE_SNAPSHOT_SCHEMA, RuntimeActionExecutionError, assertRuntimeCommandAllowed, commandAgentRunResultJson, createCommandAgentRunResult, createHostToolRegistry, createRuntimeCommandResultEnvelope, parseCommandAgentRunRequest, previewLease, resolveArtifactPath, resolveCommandPath, runtimeCommandResultEnvelopeFromOutput, runtimeEpisodeDigest } from "@automattic/wp-codebox-core"
@@ -251,8 +251,7 @@ class PlaygroundRuntime implements Runtime {
251251
private cliServerPromise?: Promise<PlaygroundCliServer>
252252
private readonly activeExecutionAbortControllers = new Set<AbortController>()
253253
private readonly executionSignals = new AsyncLocalStorage<AbortSignal>()
254-
private readonly requestWorkerExecutions = new AsyncLocalStorage<Record<string, string>>()
255-
private requestWorkerReady?: Promise<void>
254+
private readonly isolatedProcessExecutions = new AsyncLocalStorage<boolean>()
256255
private reviewerAuthBootstrapRouteRegistered = false
257256
private readonly reviewerAuthBootstraps = new Map<string, ReviewerAuthBootstrapRecord>()
258257

@@ -365,9 +364,7 @@ class PlaygroundRuntime implements Runtime {
365364
this.activeExecutionAbortControllers.add(abortController)
366365
try {
367366
const executionSpec = executionSpecWithEnvironment(spec)
368-
const output = await this.executionSignals.run(abortController.signal, async () => spec.processIdentity
369-
? await this.requestWorkerExecutions.run(spec.environment ?? {}, async () => await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController))
370-
: await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController))
367+
const output = await this.executionSignals.run(abortController.signal, async () => await this.isolatedProcessExecutions.run(Boolean(spec.processIdentity), async () => await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController)))
371368
const finishedAt = now()
372369
const envelope = typeof output === "string"
373370
? runtimeCommandResultEnvelopeFromOutput({
@@ -1769,11 +1766,11 @@ class PlaygroundRuntime implements Runtime {
17691766

17701767
private async runPlaygroundCommand(command: string, server: PlaygroundCliServer, options: { code: string } | { scriptPath: string }): Promise<PlaygroundRunResponse> {
17711768
try {
1772-
const requestWorkerEnvironment = this.requestWorkerExecutions.getStore()
1773-
if (requestWorkerEnvironment && "code" in options && server.requestWorkerEndpoint) {
1774-
await this.prepareRequestWorker(server)
1775-
const response = await this.executeRequestWorker(server, options.code, requestWorkerEnvironment, this.executionSignals.getStore())
1776-
return { text: response.text, exitCode: response.ok ? 0 : 1, ...(!response.ok ? { errors: response.text } : {}) }
1769+
if (this.isolatedProcessExecutions.getStore() && "code" in options) {
1770+
if (!server.playground.runInFreshProcess) {
1771+
throw new Error("The Playground runtime does not support clean PHP process execution.")
1772+
}
1773+
return await abortable(server.playground.runInFreshProcess(options), this.executionSignals.getStore())
17771774
}
17781775
return await abortable(server.playground.run(options), this.executionSignals.getStore())
17791776
} catch (error) {
@@ -1786,36 +1783,6 @@ class PlaygroundRuntime implements Runtime {
17861783
}
17871784
}
17881785

1789-
private async prepareRequestWorker(server: PlaygroundCliServer): Promise<void> {
1790-
if (!server.requestWorkerEndpoint) return
1791-
this.requestWorkerReady ??= (async () => {
1792-
const response = await this.executeRequestWorker(server, "<?php echo 'ready';", {})
1793-
if (!response.ok || response.text !== "ready") throw new Error(`Playground request worker warmup failed with HTTP ${response.status}: ${response.text}`)
1794-
})()
1795-
await this.requestWorkerReady
1796-
}
1797-
1798-
private async executeRequestWorker(server: PlaygroundCliServer, code: string, environment: Record<string, string>, signal?: AbortSignal): Promise<{ ok: boolean; status: number; text: string }> {
1799-
const endpoint = server.requestWorkerEndpoint
1800-
if (!endpoint) throw new Error("Playground request worker endpoint is unavailable.")
1801-
const payloadId = randomBytes(16).toString("hex")
1802-
const payloadPath = join(endpoint.payloadDirectory, `execution-${payloadId}.json`)
1803-
await writeFile(payloadPath, JSON.stringify({ code, environment }), "utf8")
1804-
try {
1805-
const response = await fetch(new URL(endpoint.route, server.serverUrl), {
1806-
method: "POST",
1807-
headers: {
1808-
"X-WP-Codebox-Execution-Token": endpoint.token,
1809-
"X-WP-Codebox-Execution-Payload": payloadId,
1810-
},
1811-
signal,
1812-
})
1813-
return { ok: response.ok, status: response.status, text: await response.text() }
1814-
} finally {
1815-
await unlink(payloadPath).catch(() => undefined)
1816-
}
1817-
}
1818-
18191786
async inspectMountedInputs(): Promise<string> {
18201787
const server = await this.bootPlayground()
18211788
const response = await server.playground.run({

packages/runtime-playground/src/preview-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ export interface PlaygroundServerRunResponse {
1111
export interface PlaygroundCliServer {
1212
playground: {
1313
run(options: { code: string } | { scriptPath: string }): Promise<PlaygroundServerRunResponse>
14+
runInFreshProcess?(options: { code: string }): Promise<PlaygroundServerRunResponse>
1415
onMessage?(listener: (data: string) => Promise<string | void> | string | void): Promise<(() => Promise<void> | void) | void> | (() => Promise<void> | void) | void
1516
readFileAsText?(path: string): string | Promise<string>
1617
writeFile?(path: string, contents: string): Promise<void>
1718
}
1819
serverUrl: string
19-
requestWorkerEndpoint?: { route: string; token: string; payloadDirectory: string }
2020
previewLease?: PreviewLease
2121
previewRoutes?: PlaygroundPreviewRouteRegistry
2222
previewProxyDiagnostics?: PlaygroundPreviewProxyDiagnostics

tests/disposable-mysql-mysqli.integration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ if (!await dockerAvailable()) {
7777
await mkdir(join(plugin, "tests"), { recursive: true })
7878
await writeFile(join(plugin, "bounded-phpunit-fixture.php"), "<?php\n/** Plugin Name: Bounded PHPUnit Fixture */\n")
7979
await writeFile(join(plugin, "phpunit.xml"), "<?xml version=\"1.0\"?><phpunit bootstrap=\"tests/bootstrap.php\"><testsuites><testsuite name=\"bounded\"><directory>tests</directory></testsuite></testsuites></phpunit>\n")
80-
await writeFile(join(plugin, "tests", "bootstrap.php"), "<?php\n")
80+
await writeFile(join(plugin, "tests", "bootstrap.php"), "<?php\nfunction add_filter(): void {}\n")
8181
await writeFile(join(plugin, "tests", "BoundedMariaDbTest.php"), `<?php
8282
final class BoundedMariaDbTest extends PHPUnit\\Framework\\TestCase {
8383
public function test_database_identity(): void {

tests/phpunit-project-autoload.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ assert.ok(projectModeCode.includes("pg_resolve_selected_test_file"))
352352
assert.ok(projectModeCode.includes("function pg_project_bootstrap_real_path"))
353353
assert.ok(projectModeCode.includes("$base_dir = dirname($xml_real);"))
354354
assert.ok(projectModeCode.includes("$bootstrap_real = pg_project_bootstrap_real_path($bootstrap, $phpunit_xml, $from_config);"))
355+
assert.ok(projectModeCode.includes("NOTICE:project bootstrap not declared; continuing without one"), "project mode permits PHPUnit configurations that do not declare a bootstrap")
355356
assert.ok(projectModeCode.includes("foreach ($xml->xpath('//testsuite/file') ?: array() as $file)"))
356357
assert.ok(projectModeCode.includes("list($directories, $suffixes, $prefixes, $excludes, $configured_files) = wp_codebox_phpunit_parse_config"))
357358
assert.ok(projectModeCode.includes("$test_files = wp_codebox_phpunit_discover($directories, $suffixes, $prefixes, $excludes, $configured_files);"))
@@ -431,7 +432,7 @@ await runPhpunitCommand({
431432
assert.ok(capturedCanonicalHarnessCode.includes('$autoload_file = "/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/autoload.php";'))
432433
assert.ok(capturedCanonicalHarnessCode.includes('$autoload_file_role = "harness";'))
433434
assert.ok(capturedCanonicalHarnessCode.includes('putenv("TC_MYSQL_PORT=3306");'), "runtime service environment is passed to the PHP executed by wordpress.phpunit")
434-
assert.ok(capturedCanonicalHarnessCode.indexOf('putenv("TC_MYSQL_PORT=3306");') < capturedCanonicalHarnessCode.indexOf("require_once '/wordpress/wp-load.php';"), "runtime environment is available to project bootstrap code")
435+
assert.ok(!capturedCanonicalHarnessCode.includes("require_once '/wordpress/wp-load.php';"), "project bootstrap mode does not load the managed WordPress runtime first")
435436

436437
let capturedExplicitCode = ""
437438
await runPhpunitCommand({

0 commit comments

Comments
 (0)