Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions packages/php-wasm/node/src/test/php-proc-open.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { spawn } from 'child_process';
import {
SupportedPHPVersions,
setPhpIniEntries,
PHP,
type SpawnHandler,
} from '@php-wasm/universal';
import { loadNodeRuntime } from '../lib';

const phpVersions =
'PHP' in process.env ? [process.env['PHP']!] : SupportedPHPVersions;

// These tests use /bin/echo and /bin/sh which are not available on Windows.
const isWindows = process.platform === 'win32';
const describeUnix = isWindows ? describe.skip : describe;

describeUnix.each(phpVersions)('PHP %s – proc_open', (phpVersion) => {
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: `<?php
$desc = [
0 => ['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: `<?php
$out = shell_exec('/bin/echo hello_from_shell_exec');
echo trim($out ?? 'NULL');
`,
});

expect(result.text).toBe('hello_from_shell_exec');
});

it('proc_open fails without spawn handler', async () => {
const result = await php.run({
code: `<?php
$desc = [1 => ['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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export class BlueprintsV1Handler {
withXdebug: !!this.args.xdebug,
nativeInternalDirPath,
pathAliases: this.args.pathAliases,
nativeSpawn: this.args.nativeSpawn,
});
await playground.isReady();
return playground;
Expand Down
42 changes: 28 additions & 14 deletions packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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;
}

/**
Expand Down Expand Up @@ -181,22 +187,30 @@ 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
? () => {
// Use child_process.spawn directly for native host process spawning.
// This runs inside the worker thread — functions can't be serialized
// across the Comlink message boundary, so we import here.
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('child_process').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);

Expand Down
18 changes: 15 additions & 3 deletions packages/playground/cli/src/run-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,9 +736,7 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
currentError = currentError.cause as Error;
} while (currentError instanceof Error);
console.error(
'\x1b[1m' +
messageChain.join(' caused by: ') +
'\x1b[0m'
'\x1b[1m' + messageChain.join(' caused by: ') + '\x1b[0m'
);
}
} else {
Expand Down Expand Up @@ -870,6 +868,20 @@ export interface RunCLIArgs {
skipBrowser?: boolean;
noAutoMount?: 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
Expand Down
Loading