diff --git a/packages/php-wasm/compile/php/phpwasm-emscripten-library.js b/packages/php-wasm/compile/php/phpwasm-emscripten-library.js index 186b9297b3..0e7f1d58a5 100644 --- a/packages/php-wasm/compile/php/phpwasm-emscripten-library.js +++ b/packages/php-wasm/compile/php/phpwasm-emscripten-library.js @@ -589,7 +589,26 @@ const LibraryExample = { } } - const cwdstr = cwdPtr ? UTF8ToString(cwdPtr) : FS.cwd(); + let cwdstr = cwdPtr ? UTF8ToString(cwdPtr) : FS.cwd(); + + // When using a native spawn handler, the VFS cwd is meaningless + // to the host OS. Passing a VFS path like "/wordpress" as cwd to + // child_process.spawn causes ENOENT. Only pass cwd if it exists + // on the host filesystem. + if (Module['spawnProcess'] && typeof require !== 'undefined') { + try { + const fs = require('fs'); + if (!fs.existsSync(cwdstr)) { + cwdstr = null; + } + } catch (e) { + cwdstr = null; + } + } else if (Module['spawnProcess']) { + // ESM environment — can't sync-check, skip VFS cwd + cwdstr = null; + } + let envObject = null; if (envLength) { diff --git a/packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts b/packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts new file mode 100644 index 0000000000..06d2f19a9b --- /dev/null +++ b/packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts @@ -0,0 +1,193 @@ +/** + * Tests proc_open behavior across different execution contexts. + * + * proc_open works with php.run() but fails through the daemon/CLI path. + * These tests isolate where the spawn handler gets lost: + * + * 1. php.run() — direct PHP execution (known working) + * 2. php.cli() — WP-CLI-style execution on same instance + * 3. Runtime rotation — does enableRuntimeRotation preserve the handler? + * 4. Worker thread — does the worker thread path preserve the handler? + */ +import { spawn } from 'child_process'; +import { PHP, ProcessIdAllocator } from '@php-wasm/universal'; +import { loadNodeRuntime } from '../lib'; + +const isWindows = process.platform === 'win32'; +const describeUnix = isWindows ? describe.skip : describe; + +const processIdAllocator = new ProcessIdAllocator(); + +const PROC_OPEN_TEST_CODE = ` ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = proc_open('/bin/echo proc_open_works', $desc, $pipes); + if (is_resource($proc)) { + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($proc); + echo trim($stdout); + } else { + echo 'PROC_OPEN_FAILED'; + } +`; + +describeUnix('proc_open spawn handler propagation', () => { + let php: PHP; + + afterEach(() => { + try { + php.exit(); + } catch { + // ignore + } + }); + + it('works with php.run() — baseline', async () => { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + + const result = await php.run({ code: PROC_OPEN_TEST_CODE }); + expect(result.text).toBe('proc_open_works'); + }); + + it('works with php.cli() — CLI execution path', async () => { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + await php.setSapiName('cli'); + + php.writeFile('/tmp/test-proc-open.php', PROC_OPEN_TEST_CODE); + + const result = await php.cli(['php', '/tmp/test-proc-open.php']); + const stdout = await result.stdoutText; + expect(stdout.trim()).toBe('proc_open_works'); + }); + + it('survives runtime rotation', async () => { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + + // Enable runtime rotation (this is what bootRequestHandler does) + php.enableRuntimeRotation({ + maxRequests: 400, + recreateRuntime: () => + loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }), + }); + + const result = await php.run({ code: PROC_OPEN_TEST_CODE }); + expect(result.text).toBe('proc_open_works'); + }); + + it('survives runtime rotation + cli()', async () => { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + await php.setSapiName('cli'); + + php.enableRuntimeRotation({ + maxRequests: 400, + recreateRuntime: () => + loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }), + }); + + php.writeFile('/tmp/test-proc-open.php', PROC_OPEN_TEST_CODE); + + const result = await php.cli(['php', '/tmp/test-proc-open.php']); + const stdout = await result.stdoutText; + expect(stdout.trim()).toBe('proc_open_works'); + }); + + it('works after runtime has been rotated', async () => { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + + php.enableRuntimeRotation({ + maxRequests: 1, // Force rotation after every request + recreateRuntime: () => + loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }), + }); + + // First request — uses original runtime + const result1 = await php.run({ + code: ' { + php = new PHP( + await loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }) + ); + await php.setSpawnHandler(spawn as any); + await php.setSapiName('cli'); + + php.enableRuntimeRotation({ + maxRequests: 1, // Force rotation after every request + recreateRuntime: () => + loadNodeRuntime('8.3', { + emscriptenOptions: { + processId: processIdAllocator.claim(), + }, + }), + }); + + // First request triggers rotation + await php.run({ code: ' { + let php: PHP; + + beforeEach(async () => { + php = new PHP(await loadNodeRuntime(phpVersion as any)); + await setPhpIniEntries(php, { allow_url_fopen: 1 }); + }); + + afterEach(() => { + php.exit(); + }); + + it('proc_open works with native spawn handler', async () => { + await php.setSpawnHandler(spawn as unknown as SpawnHandler); + + const result = await php.run({ + code: ` ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open('/bin/echo hello_from_proc_open', $desc, $pipes); + if (is_resource($proc)) { + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($proc); + echo trim($stdout); + } else { + echo 'PROC_OPEN_FAILED'; + } + `, + }); + + expect(result.text).toBe('hello_from_proc_open'); + }); + + it('shell_exec works with native spawn handler', async () => { + await php.setSpawnHandler(spawn as unknown as SpawnHandler); + + const result = await php.run({ + code: ` { + const result = await php.run({ + code: ` ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = @proc_open('echo test', $desc, $pipes); + echo is_resource($proc) ? 'OPENED' : 'FAILED'; + `, + }); + + // Without a spawn handler, proc_open should fail + expect(result.text).toBe('FAILED'); + }); +}); diff --git a/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts b/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts index a30ca4e01b..8a6a5f9f63 100644 --- a/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts +++ b/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts @@ -203,6 +203,7 @@ export class BlueprintsV1Handler { withXdebug: !!this.args.xdebug, nativeInternalDirPath, pathAliases: this.args.pathAliases, + nativeSpawn: this.args.nativeSpawn, }); await playground.isReady(); return playground; diff --git a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts index a5defa6d70..ebcb899695 100644 --- a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts +++ b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts @@ -1,4 +1,5 @@ import type { FileLockManager } from '@php-wasm/universal'; +import { spawn } from 'child_process'; import { loadNodeRuntime } from '@php-wasm/node'; import { EmscriptenDownloadMonitor } from '@php-wasm/progress'; import type { AllPHPVersion, PathAlias } from '@php-wasm/universal'; @@ -53,6 +54,12 @@ interface WorkerBootRequestHandlerOptions { withMemcached?: boolean; withXdebug?: boolean; pathAliases?: PathAlias[]; + /** + * When true, uses native child_process.spawn for PHP's proc_open(), + * shell_exec(), etc. instead of the sandboxed handler that spawns + * new PHP WASM instances. Only works in Node.js environments. + */ + nativeSpawn?: boolean; } /** @@ -185,22 +192,24 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { sapiName: 'cli', cookieStore: false, pathAliases: options.pathAliases, - spawnHandler: () => - sandboxedSpawnHandlerFactory(() => { - let effectiveOptions = options; - if (!this.bootedWordPress) { - // WordPress is not yet booted so skip the post-install mounts. - effectiveOptions = { - ...options, - mountsAfterWpInstall: [], - }; - } + spawnHandler: options.nativeSpawn + ? () => spawn + : () => + sandboxedSpawnHandlerFactory(() => { + let effectiveOptions = options; + if (!this.bootedWordPress) { + // WordPress is not yet booted so skip the post-install mounts. + effectiveOptions = { + ...options, + mountsAfterWpInstall: [], + }; + } - return createPHPWorker( - effectiveOptions, - this.fileLockManager! - ); - }), + return createPHPWorker( + effectiveOptions, + this.fileLockManager! + ); + }), }); this.__internal_setRequestHandler(requestHandler); diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 1c29604a4e..1733e20358 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -934,6 +934,20 @@ export interface RunCLIArgs { path?: string; skipBrowser?: boolean; reset?: boolean; + + /** + * When true, uses native child_process.spawn for PHP's proc_open(), + * shell_exec(), etc. instead of the sandboxed handler that spawns + * new PHP WASM instances. Only works in Node.js environments. + * + * This enables PHP code to spawn host processes — useful for plugins + * that use proc_open() to communicate with external tools. + * + * Warning: Enabling this allows PHP code to execute arbitrary commands + * on the host. Only enable for trusted code and blueprints. Do not + * enable in multi-tenant environments or when running untrusted input. + */ + nativeSpawn?: boolean; } // TODO: Maybe we should just be declaring an interface instead of a type union