diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 98e5648d..f3d6f8ea 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -134,7 +134,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: emitProgress("preview:activating-dependencies", "running", "Activating site features", blueprintSummary) } - const server = useProgrammaticRunner ? await startPlaygroundCliWithDynamicPortRetry(async (port) => { + const server = withPhpEnvOnPlaygroundRun(useProgrammaticRunner ? await startPlaygroundCliWithDynamicPortRetry(async (port) => { return startProgrammaticPlaygroundServer({ ...spec, preview: { @@ -192,7 +192,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: } finally { await localAssetServer?.close() } - }, Boolean(spec.preview?.port)) + }, Boolean(spec.preview?.port)), spec) emitProgress("preview:connecting-client", "running", "Connecting preview", { localUrl: server.serverUrl, @@ -544,11 +544,18 @@ if ($wpcb_db_endpoint['host_class'] === 'absent' || !$wpcb_db_endpoint['port'][' if (!$wpcb_db_diagnostic['transport']['stream_socket_client'] || !$wpcb_db_diagnostic['transport']['mysqli']) $wpcb_db_fail('transport_unavailable', 'Use a runtime with TCP sockets and the mysqli extension enabled.'); $wpcb_db_target = strpos($wpcb_db_host, ':') !== false ? 'tcp://[' . $wpcb_db_host . ']:' . $wpcb_db_port : 'tcp://' . $wpcb_db_host . ':' . $wpcb_db_port; $wpcb_db_diagnostic['tcp']['attempted'] = true; $wpcb_db_socket = @stream_socket_client($wpcb_db_target, $wpcb_db_tcp_errno, $wpcb_db_tcp_error, 2, STREAM_CLIENT_CONNECT); if (!$wpcb_db_socket) { $wpcb_db_diagnostic['tcp']['error_code'] = (int) $wpcb_db_tcp_errno; $wpcb_db_fail('endpoint_unreachable', 'Verify runtime network access to the managed database endpoint.'); } fclose($wpcb_db_socket); $wpcb_db_diagnostic['tcp']['connected'] = true; -$wpcb_db_diagnostic['mysqli']['attempted'] = true; $wpcb_db = @mysqli_init(); if (!$wpcb_db) $wpcb_db_fail('transport_unavailable', 'The mysqli client could not initialize in this runtime.'); @mysqli_options($wpcb_db, MYSQLI_OPT_CONNECT_TIMEOUT, 2); $wpcb_db_user = getenv('DB_USER') ?: 'root'; $wpcb_db_name = getenv('DB_NAME') ?: 'runtime'; $wpcb_db_connected = @mysqli_real_connect($wpcb_db, $wpcb_db_host, $wpcb_db_user, getenv('DB_PASSWORD'), $wpcb_db_name, (int) $wpcb_db_port); if (!$wpcb_db_connected) { $wpcb_db_errno = (int) mysqli_connect_errno(); $wpcb_db_diagnostic['mysqli']['error_code'] = $wpcb_db_errno; $wpcb_db_fail($wpcb_db_errno === 1045 ? 'authentication_failed' : ($wpcb_db_errno === 1049 ? 'database_missing' : 'endpoint_unreachable'), 'Verify the managed database credentials and selected database.'); } mysqli_close($wpcb_db); $wpcb_db_diagnostic['mysqli']['connected'] = true; +$wpcb_db_password = getenv('DB_PASSWORD'); +if (is_string($wpcb_db_password) && $wpcb_db_password !== '') { + try { + $wpcb_db_diagnostic['mysqli']['attempted'] = true; $wpcb_db = @mysqli_init(); if (!$wpcb_db) $wpcb_db_fail('transport_unavailable', 'The mysqli client could not initialize in this runtime.'); @mysqli_options($wpcb_db, MYSQLI_OPT_CONNECT_TIMEOUT, 2); $wpcb_db_user = getenv('DB_USER') ?: 'root'; $wpcb_db_name = getenv('DB_NAME') ?: 'runtime'; $wpcb_db_connected = @mysqli_real_connect($wpcb_db, $wpcb_db_host, $wpcb_db_user, $wpcb_db_password, $wpcb_db_name, (int) $wpcb_db_port); if (!$wpcb_db_connected) { $wpcb_db_errno = (int) mysqli_connect_errno(); $wpcb_db_diagnostic['mysqli']['error_code'] = $wpcb_db_errno; $wpcb_db_fail($wpcb_db_errno === 1045 ? 'authentication_failed' : ($wpcb_db_errno === 1049 ? 'database_missing' : 'endpoint_unreachable'), 'Verify the managed database credentials and selected database.'); } mysqli_close($wpcb_db); $wpcb_db_diagnostic['mysqli']['connected'] = true; + } catch (Throwable $wpcb_db_error) { + $wpcb_db_errno = (int) mysqli_connect_errno(); $wpcb_db_diagnostic['mysqli']['error_code'] = $wpcb_db_errno; $wpcb_db_fail($wpcb_db_errno === 1045 ? 'authentication_failed' : ($wpcb_db_errno === 1049 ? 'database_missing' : 'endpoint_unreachable'), 'Verify the managed database credentials and selected database.'); + } +} ` } -function runtimePhpEnvironment(spec: RuntimeCreateSpec): Record | undefined { +export function runtimePhpEnvironment(spec: RuntimeCreateSpec): Record | undefined { if (spec.environment.databaseSetup !== "external") return undefined const environment = { ...(spec.runtimeEnv ?? {}), @@ -557,6 +564,24 @@ function runtimePhpEnvironment(spec: RuntimeCreateSpec): Record return Object.keys(environment).length > 0 ? environment : undefined } +export function playgroundRunOptionsWithPhpEnv }>(options: T, phpEnv: Record | undefined): T { + if (!phpEnv) return options + return { ...options, env: { ...options.env, ...phpEnv } } +} + +function withPhpEnvOnPlaygroundRun(server: PlaygroundCliServer, spec: RuntimeCreateSpec): PlaygroundCliServer { + const phpEnv = runtimePhpEnvironment(spec) + if (!phpEnv) return server + const inner = server.playground + return { + ...server, + playground: { + ...inner, + run: (options) => inner.run(playgroundRunOptionsWithPhpEnv(options, phpEnv)), + }, + } +} + function distributionBootstrapPhp(spec: RuntimeCreateSpec): string { const distribution = recipeDistribution(spec) if (!distribution) { diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index c260f0c0..f7b56be0 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -18,7 +18,7 @@ import { argValue, cleanWpCliOutput, runWithTemporaryWpCliScript, shellArgv, wor import { bootstrapPhpCode } from "./php-bootstrap.js" import { observeHttpResponse as observeHttpResponseArtifact, observeWordPressState as observeWordPressStateArtifact } from "./observation-artifacts.js" import { PlaygroundCommandCrashError, assertPlaygroundResponseOk, errorMessage, terminalizeOnPhpWasmRuntimeRejection, type PlaygroundRunResponse } from "./playground-command-errors.js" -import { startPlaygroundCliServer, type PlaygroundCliModule } from "./playground-cli-runner.js" +import { runtimePhpEnvironment, startPlaygroundCliServer, type PlaygroundCliModule } from "./playground-cli-runner.js" import type { PlaygroundCliServer } from "./preview-server.js" import { collectPlaygroundArtifacts } from "./runtime-artifact-helpers.js" import { materializePlaygroundMountsFromVfs, materializePlaygroundStagedFiles } from "./mount-materialization.js" @@ -1807,9 +1807,10 @@ class PlaygroundRuntime implements Runtime { private async runPlaygroundCommand(command: string, server: PlaygroundCliServer, options: { code: string } | { scriptPath: string }): Promise { try { const requestWorkerEnvironment = this.requestWorkerExecutions.getStore() + const phpEnv = runtimePhpEnvironment(this.spec) if (requestWorkerEnvironment && "code" in options && server.requestWorkerEndpoint) { await this.prepareRequestWorker(server) - const response = await this.executeRequestWorker(server, options.code, requestWorkerEnvironment, this.executionSignals.getStore()) + const response = await this.executeRequestWorker(server, options.code, { ...requestWorkerEnvironment, ...phpEnv }, this.executionSignals.getStore()) return { text: response.text, exitCode: response.ok ? 0 : 1, ...(!response.ok ? { errors: response.text } : {}) } } const response = await abortable(server.playground.run(options), this.executionSignals.getStore()) diff --git a/packages/runtime-playground/src/programmatic-playground-runner.ts b/packages/runtime-playground/src/programmatic-playground-runner.ts index 042f728a..165ffa02 100644 --- a/packages/runtime-playground/src/programmatic-playground-runner.ts +++ b/packages/runtime-playground/src/programmatic-playground-runner.ts @@ -24,7 +24,7 @@ interface ProgrammaticPHP { mount(vfsPath: string, mountHandler: unknown): Promise onMessage(listener: (data: string) => Promise | string | void): () => Promise readFileAsText(path: string): string - run(options: { code: string } | { scriptPath: string }): Promise + run(options: ({ code: string } | { scriptPath: string }) & { env?: Record }): Promise unlink(path: string): void writeFile(path: string, contents: string): void } @@ -217,7 +217,7 @@ async function applyBlueprint(php: ProgrammaticPHP, spec: RuntimeCreateSpec): Pr await compiled.run(php as never) } -async function runPhp(php: ProgrammaticPHP, options: { code: string } | { scriptPath: string }): Promise { +async function runPhp(php: ProgrammaticPHP, options: ({ code: string } | { scriptPath: string }) & { env?: Record }): Promise { const response = await php.run(options) return normalizePhpResponse(response) } diff --git a/tests/external-mysql-runtime-service.test.ts b/tests/external-mysql-runtime-service.test.ts index 83592159..46092de7 100644 --- a/tests/external-mysql-runtime-service.test.ts +++ b/tests/external-mysql-runtime-service.test.ts @@ -185,7 +185,7 @@ try { const bootstrapRuns: Array<({ code: string } | { scriptPath: string }) & { env?: Record }> = [] const cliModule: PlaygroundCliModule = { async runCLI(options) { bootstrapCalls.push(options) - return { serverUrl: "http://127.0.0.1:65535", playground: { async run(runOptions) { bootstrapRuns.push(runOptions); return { text: options.phpEnv?.DB_PASSWORD ?? "" } } }, async [Symbol.asyncDispose]() {} } + return { serverUrl: "http://127.0.0.1:65535", playground: { async run(runOptions) { bootstrapRuns.push(runOptions); return { text: runOptions.env?.DB_PASSWORD ?? "" } } }, async [Symbol.asyncDispose]() {} } } } const secondaryConnectorSecret = "secondary-connector-secret" const runtimeSpec: RuntimeCreateSpec = { @@ -210,7 +210,7 @@ try { await server.playground.run({ code: generatedCommandPhp }) await server.playground.run({ code: generatedAbilityPhp }) assert.equal(connectorResponse.text, generatedPassword, "generated password reaches PHP through the ephemeral run environment") - assert.equal(bootstrapRuns[0]?.env?.DB_PASSWORD, undefined, "direct runs rely on the isolated PHP runtime environment") + assert.equal(bootstrapRuns[0]?.env?.DB_PASSWORD, generatedPassword, "direct runs receive connector secrets through PHP.run env") assert.equal(bootstrapRuns.every((run) => !("code" in run) || !run.code.includes(generatedPassword)), true, "captured PHP source never contains the connector password") assert.equal(bootstrapCalls[0]?.phpEnv?.DB_PASSWORD, generatedPassword, "Playground startup receives the generated password through its in-memory PHP environment") assert.equal(bootstrapCalls[0]?.phpEnv?.CACHE_AUTH, secondaryConnectorSecret, "multiple connector targets resolve through the same in-memory channel") diff --git a/tests/playground-cli-php-env-propagation.test.ts b/tests/playground-cli-php-env-propagation.test.ts new file mode 100644 index 00000000..0635d14f --- /dev/null +++ b/tests/playground-cli-php-env-propagation.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict" +import { createServer } from "node:http" +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createRuntime } from "../packages/runtime-core/src/index.js" +import { createPlaygroundRuntimeBackend } from "../packages/runtime-playground/src/index.js" +import { playgroundRunOptionsWithPhpEnv, runtimePhpEnvironment, type PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.js" + +const root = await mkdtemp(join(tmpdir(), "wp-codebox-php-env-propagation-")) +const wordpressDirectory = join(root, "wordpress") +const artifactsDirectory = join(root, "artifacts") +await mkdir(wordpressDirectory) +const payloads: Array<{ code?: string; environment?: Record }> = [] + +const upstream = createServer(async (request, response) => { + const payloadId = request.headers["x-wp-codebox-execution-payload"] + if (typeof payloadId !== "string") { + response.writeHead(200) + response.end("ok") + return + } + const payload = JSON.parse(await readFile(join(artifactsDirectory, "playground-internal-shared", `execution-${payloadId}.json`), "utf8")) as { code?: string; environment?: Record } + payloads.push(payload) + response.writeHead(200, { "content-type": "text/plain" }) + response.end(payload.code?.includes("echo 'ready'") ? "ready" : String(payload.environment?.DB_PASSWORD ?? "")) +}) + +await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)) +const address = upstream.address() +assert.ok(address && typeof address === "object") +const serverUrl = `http://127.0.0.1:${address.port}` + +const cliModule: PlaygroundCliModule = { + async runCLI() { + return { + serverUrl, + playground: { + async run() { + return { text: "" } + }, + }, + async [Symbol.asyncDispose]() {}, + } + }, +} + +const spec = { + backend: "wordpress-playground" as const, + artifactsDirectory, + environment: { + kind: "wordpress" as const, + name: "php-env-propagation", + version: "mounted-wordpress-source", + phpVersion: "8.4", + wordpressInstallMode: "do-not-attempt-installing" as const, + databaseSetup: "external" as const, + assets: { wordpressDirectory }, + blueprint: {}, + }, + policy: { + network: "deny" as const, + filesystem: "sandbox" as const, + commands: ["wordpress.run-php"], + secrets: "none" as const, + approvals: "never" as const, + }, + runtimeEnv: { DB_HOST: "127.0.0.1", DB_PORT: "33061", DB_USER: "runtime", DB_NAME: "runtime" }, + secretEnv: { DB_PASSWORD: "connector-secret" }, + secretEnvTargets: { DB_PASSWORD: "DB_PASSWORD" }, +} + +assert.equal(playgroundRunOptionsWithPhpEnv({ code: " payload.code?.includes("getenv('DB_PASSWORD')")) + assert.equal(commandPayload?.environment?.DB_PASSWORD, "connector-secret", "isolated PHPUnit request workers must receive connector secrets") + assert.equal(execution.stdout, "connector-secret") +} finally { + await runtime.destroy() + await new Promise((resolve, reject) => upstream.close((error) => error ? reject(error) : resolve())) + await rm(root, { recursive: true, force: true }) +} + +console.log("playground cli php env propagation ok") diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index e3b28341..c0320a3f 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -19,7 +19,7 @@ const cliModule: PlaygroundCliModule = { playground: { async run(runOptions) { runs.push(runOptions) - return { text: options.phpEnv?.DB_PASSWORD ?? "" } + return { text: runOptions.env?.DB_PASSWORD ?? "" } }, }, async [Symbol.asyncDispose]() {}, @@ -120,7 +120,8 @@ try { assert.match(sharedAutoPrepend, /getenv\('DB_USER'\) \?: 'root'/) assert.match(sharedAutoPrepend, /getenv\('DB_NAME'\) \?: 'runtime'/) assert.doesNotMatch(sharedAutoPrepend, /secret/) - assert.equal(runs[0]?.env?.DB_PASSWORD, undefined) + assert.equal(runs[0]?.env?.DB_PASSWORD, "secret") + assert.deepEqual(runs[0]?.env, calls[0].phpEnv) const requestWorkerPath = calls[0]["mount-before-install"]?.[3]?.hostPath assert.equal(typeof requestWorkerPath, "string") assert.doesNotMatch(await readFile(requestWorkerPath as string, "utf8"), /secret/)