From 6f2b2288ad74a8b30962c204dbef38043fbabe59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 30 Sep 2025 13:38:13 +0200 Subject: [PATCH 1/4] [PHP] Add "ls" and "pwd" CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced sandboxedSpawnHandlerFactory support for `ls` and `pwd` commands. ## Testing instructions CI – this PR comes with a unit test. --- packages/php-wasm/node/src/test/php.spec.ts | 76 ++++++++++++ packages/php-wasm/universal/src/lib/php.ts | 9 ++ .../lib/sandboxed-spawn-handler-factory.ts | 113 ++++++++++++------ 3 files changed, 159 insertions(+), 39 deletions(-) diff --git a/packages/php-wasm/node/src/test/php.spec.ts b/packages/php-wasm/node/src/test/php.spec.ts index b7f5e6527b..cdb807fcde 100644 --- a/packages/php-wasm/node/src/test/php.spec.ts +++ b/packages/php-wasm/node/src/test/php.spec.ts @@ -3,7 +3,10 @@ import { getPhpIniEntries, loadPHPRuntime, PHP, + PHPProcessManager, + sandboxedSpawnHandlerFactory, setPhpIniEntries, + type SpawnedPHP, SupportedPHPVersions, } from '@php-wasm/universal'; import { createSpawnHandler, joinPaths, phpVar } from '@php-wasm/util'; @@ -21,6 +24,7 @@ import { vi } from 'vitest'; import { getPHPLoaderModule, loadNodeRuntime } from '..'; import type { PHPLoaderOptions } from '..'; import { createNodeFsMountHandler } from '../lib/node-fs-mount'; +import { RecommendedPHPVersion } from '@wp-playground/common'; const testDirPath = '/__test987654321'; const testFilePath = '/__test987654321.txt'; @@ -2943,3 +2947,75 @@ phpLoaderOptions.forEach((options) => { }); }); }); + +describe('sandboxedSpawnHandlerFactory', () => { + const phpVersion = RecommendedPHPVersion; + let php: PHP; + let spawnedPhp: SpawnedPHP; + let processManager: PHPProcessManager; + beforeEach(async () => { + processManager = new PHPProcessManager({ + phpFactory: async () => { + const php = new PHP( + await loadNodeRuntime(phpVersion as any, {}) + ); + php.mkdir('/tmp/shared-test-directory'); + php.chdir('/tmp/shared-test-directory'); + + php.writeFile( + '/tmp/shared-test-directory/README.md', + 'Hello, world!' + ); + php.mkdir('/tmp/shared-test-directory/code'); + php.writeFile( + '/tmp/shared-test-directory/code/index.php', + 'Hello, world!' + ); + await php.setSpawnHandler( + sandboxedSpawnHandlerFactory(processManager) + ); + return php; + }, + maxPhpInstances: 5, + }); + spawnedPhp = await processManager.acquirePHPInstance(); + php = spawnedPhp.php; + }); + afterEach(async () => { + await processManager[Symbol.asyncDispose](); + spawnedPhp?.reap(); + }); + it.each([ + // Default cwd + { + command: 'ls', + expected: ['README.md', 'code', ''].join('\n'), + }, + // Explicit path + { + command: 'ls /tmp/shared-test-directory', + expected: ['README.md', 'code', ''].join('\n'), + }, + // Subdirectory – we expect a different output + { + command: 'ls /tmp/shared-test-directory/code', + expected: ['index.php', ''].join('\n'), + }, + // pwd + { + command: 'pwd', + expected: '/tmp/shared-test-directory\n', + }, + ])('should be able to run "$command"', async ({ command, expected }) => { + const response = await php.run({ + code: ` { + processApi.on('stdin', (data) => { processApi.stdout(data); }); // Exit after the stdin stream is exhausted. @@ -55,49 +55,84 @@ export function sandboxedSpawnHandlerFactory( }); processApi.exit(0); return; - } else if (binaryName === 'php') { - const { php, reap } = await processManager.acquirePHPInstance({ - considerPrimary: false, - }); + } - php.chdir(options.cwd as string); - try { - // Figure out more about setting env, putenv(), etc. - const result = await php.cli(args, { - env: { - ...options.env, - SCRIPT_PATH: args[1], - // Set SHELL_PIPE to 0 to ensure WP-CLI formats - // the output as ASCII tables. - // @see https://github.com/wp-cli/wp-cli/issues/1102 - SHELL_PIPE: '0', - }, - }); + if (!['php', 'ls', 'pwd'].includes(binaryName ?? '')) { + // 127 is the exit code "for command not found". + processApi.exit(127); + return; + } - result.stdout.pipeTo( - new WritableStream({ - write(chunk) { - processApi.stdout(chunk); - }, - }) - ); - result.stderr.pipeTo( - new WritableStream({ - write(chunk) { - processApi.stderr(chunk); + const { php, reap } = await processManager.acquirePHPInstance({ + considerPrimary: false, + }); + + try { + if (options.cwd) { + php.chdir(options.cwd as string); + } + + const cwd = php.cwd(); + switch (binaryName) { + case 'php': { + // Figure out more about setting env, putenv(), etc. + const result = await php.cli(args, { + env: { + ...options.env, + SCRIPT_PATH: args[1], + // Set SHELL_PIPE to 0 to ensure WP-CLI formats + // the output as ASCII tables. + // @see https://github.com/wp-cli/wp-cli/issues/1102 + SHELL_PIPE: '0', }, - }) - ); - processApi.exit(await result.exitCode); - } catch (e) { - // An exception here means the PHP runtime has crashed. - processApi.exit(1); - throw e; - } finally { - reap(); + }); + + result.stdout.pipeTo( + new WritableStream({ + write(chunk) { + processApi.stdout(chunk as any as ArrayBuffer); + }, + }) + ); + result.stderr.pipeTo( + new WritableStream({ + write(chunk) { + processApi.stderr(chunk as any as ArrayBuffer); + }, + }) + ); + processApi.exit(await result.exitCode); + break; + } + case 'ls': { + const files = php.listFiles(args[1] ?? cwd); + for (const file of files) { + processApi.stdout(file + '\n'); + } + // Technical limitation of subprocesses – we need to + // wait before exiting to give consumer a chance to read + // the output. + await new Promise((resolve) => setTimeout(resolve, 10)); + processApi.exit(0); + break; + } + case 'pwd': { + processApi.stdout(cwd + '\n'); + // Technical limitation of subprocesses – we need to + // wait before exiting to give consumer a chance to read + // the output. + await new Promise((resolve) => setTimeout(resolve, 10)); + processApi.exit(0); + break; + } } - } else { + } catch (e) { + console.error(e); + // An exception here means the PHP runtime has crashed. processApi.exit(1); + throw e; + } finally { + reap(); } }); } From 8a36c93b080c07bc8978f5c4368b2f4364494ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 30 Sep 2025 13:50:29 +0200 Subject: [PATCH 2/4] Lint --- .../universal/src/lib/sandboxed-spawn-handler-factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts b/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts index 0ae5de6e4f..b59bad6794 100644 --- a/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts +++ b/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts @@ -1,5 +1,6 @@ import { createSpawnHandler } from '@php-wasm/util'; import type { PHPProcessManager } from './php-process-manager'; +import { logger } from '@php-wasm/logger'; /** * An isomorphic proc_open() handler that implements typical shell in TypeScript @@ -127,7 +128,6 @@ export function sandboxedSpawnHandlerFactory( } } } catch (e) { - console.error(e); // An exception here means the PHP runtime has crashed. processApi.exit(1); throw e; From 9bf5ea5051e8fb7bf3902416c64cd60475150987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 30 Sep 2025 13:53:02 +0200 Subject: [PATCH 3/4] [PHP] Support subprocesses in cli() calls --- packages/php-wasm/universal/src/lib/php.ts | 80 +++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index 4395bc9090..4829fa32b2 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -9,6 +9,7 @@ import { getLoadedRuntime } from './load-php-runtime'; import type { PHPRequestHandler } from './php-request-handler'; import { PHPResponse, StreamedPHPResponse } from './php-response'; import type { + ChildProcess, MessageListener, PHPEvent, PHPEventListener, @@ -1432,8 +1433,12 @@ export class PHP implements Disposable { */ async cli( argv: string[], - options: { env?: Record } = {} + options: { env?: Record; cwd?: string } = {} ): Promise { + if (argv[0] !== 'php' && !argv[0].endsWith('/php')) { + return this.subProcess(argv, options); + } + if (this.#phpWasmInitCalled) { this.#rotationOptions.needsRotating = true; } @@ -1469,6 +1474,79 @@ export class PHP implements Disposable { }); } + /** + * Runs an arbitrary CLI command using the spawn handler associated + * with this PHP instance. + * + * @param argv + * @param options + * @returns StreamedPHPResponse. + */ + private async subProcess( + argv: string[], + options: { env?: Record; cwd?: string } = {} + ): Promise { + const process = this[__private__dont__use].spawnProcess( + argv[0], + argv.slice(1), + { + env: options.env, + cwd: this.cwd(), + } + ) as ChildProcess; + + const stderrStream = await createInvertedReadableStream(); + process.on('error', (error) => { + stderrStream.controller.error(error); + }); + process.stderr.on('data', (data) => { + stderrStream.controller.enqueue(data); + }); + + const stdoutStream = await createInvertedReadableStream(); + process.stdout.on('data', (data) => { + stdoutStream.controller.enqueue(data); + }); + + process.on('exit', () => { + // Delay until next tick to ensure we don't close the streams before + // emitting the error event on the stderrStream. + setTimeout(() => { + /** + * Ignore any close() errors, e.g. "stream already closed". We just + * need to try to call close() and forget about this subprocess. + */ + try { + stderrStream.controller.close(); + } catch { + // Ignore error + } + try { + stdoutStream.controller.close(); + } catch { + // Ignore error + } + }, 0); + }); + + return new StreamedPHPResponse( + // Headers stream + new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + stdoutStream.stream, + stderrStream.stream, + // Exit code + new Promise((resolve) => { + process.on('exit', (code) => { + resolve(code); + }); + }) + ); + } + setSkipShebang(shouldSkip: boolean) { this[__private__dont__use].ccall( 'wasm_set_skip_shebang', From 5605ff6b0c0866316d7e72fbbb1459e647290c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 30 Sep 2025 13:57:38 +0200 Subject: [PATCH 4/4] Add a unit test --- packages/php-wasm/node/src/test/php.spec.ts | 7 +++++++ packages/php-wasm/universal/src/lib/php.ts | 11 ++++++++--- .../src/lib/sandboxed-spawn-handler-factory.ts | 1 - 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/php-wasm/node/src/test/php.spec.ts b/packages/php-wasm/node/src/test/php.spec.ts index cdb807fcde..3ceac8b140 100644 --- a/packages/php-wasm/node/src/test/php.spec.ts +++ b/packages/php-wasm/node/src/test/php.spec.ts @@ -3018,4 +3018,11 @@ describe('sandboxedSpawnHandlerFactory', () => { }); expect(response.text).toEqual(expected); }); + + it('Should be able to run CLI commands via php.cli()', async () => { + const response = await php.cli(['ls', '/tmp/shared-test-directory']); + expect(await response.stdoutText).toEqual( + ['README.md', 'code', ''].join('\n') + ); + }); }); diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index 4829fa32b2..1821678ea1 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -1,5 +1,10 @@ import { logger } from '@php-wasm/logger'; -import { Semaphore, createSpawnHandler, joinPaths } from '@php-wasm/util'; +import { + Semaphore, + basename, + createSpawnHandler, + joinPaths, +} from '@php-wasm/util'; import type { Emscripten } from './emscripten-types'; import type { ListFilesOptions, RmDirOptions } from './fs-helpers'; import { FSHelpers } from './fs-helpers'; @@ -1435,7 +1440,7 @@ export class PHP implements Disposable { argv: string[], options: { env?: Record; cwd?: string } = {} ): Promise { - if (argv[0] !== 'php' && !argv[0].endsWith('/php')) { + if (basename(argv[0] ?? '') !== 'php') { return this.subProcess(argv, options); } @@ -1491,7 +1496,7 @@ export class PHP implements Disposable { argv.slice(1), { env: options.env, - cwd: this.cwd(), + cwd: options.cwd ?? this.cwd(), } ) as ChildProcess; diff --git a/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts b/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts index b59bad6794..de28dcab5e 100644 --- a/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts +++ b/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts @@ -1,6 +1,5 @@ import { createSpawnHandler } from '@php-wasm/util'; import type { PHPProcessManager } from './php-process-manager'; -import { logger } from '@php-wasm/logger'; /** * An isomorphic proc_open() handler that implements typical shell in TypeScript