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
21 changes: 20 additions & 1 deletion packages/php-wasm/compile/php/phpwasm-emscripten-library.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
193 changes: 193 additions & 0 deletions packages/php-wasm/node/src/test/php-proc-open-daemon.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `<?php
$desc = [0 => ['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 echo "first";',
});
expect(result1.text).toBe('first');

// Second request — runtime has been rotated
const result2 = await php.run({ code: PROC_OPEN_TEST_CODE });
expect(result2.text).toBe('proc_open_works');
});

it('works after runtime rotation via 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: 1, // Force rotation after every request
recreateRuntime: () =>
loadNodeRuntime('8.3', {
emscriptenOptions: {
processId: processIdAllocator.claim(),
},
}),
});

// First request triggers rotation
await php.run({ code: '<?php echo "warmup";' });

// Second request on rotated runtime, via 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');
});
});
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);
Comment thread
chubes4 marked this conversation as resolved.
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 @@ -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;
Expand Down
39 changes: 24 additions & 15 deletions packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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);

Expand Down
14 changes: 14 additions & 0 deletions packages/playground/cli/src/run-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
chubes4 marked this conversation as resolved.
*
* 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