Summary
studio wp <command> silently drops host stdin when the target site's WordPress server is running. Any wp-cli invocation that reads from php://stdin sees an empty string; there is no error, no warning, and the command returns a 0 exit code — so callers can believe their operation succeeded when in fact no user data reached PHP at all.
Canonical reproduction
$ echo "hello-from-host-stdin" | studio wp eval 'echo "got: [" . file_get_contents("php://stdin") . "]";'
got: []
Expected: got: [hello-from-host-stdin].
100% reproducible on a fresh shell against any running Studio site. The same command works correctly when the site is not running (Studio falls through to a different code path — see Why below).
Impact
Breaks the entire class of wp-cli workflows that use stdin. This is idiomatic wp-cli usage, not an edge case:
- Database restore:
gunzip -c backup.sql.gz | studio wp db query — appears to succeed, imports nothing
- Raw SQL:
studio wp db query < file.sql — no rows processed
- Content creation:
studio wp post create --post_content=- (wp-cli's - sentinel) — creates an empty post
- Import:
cat export.xml | studio wp import - — imports nothing
- Running a script from stdin:
cat script.php | studio wp eval-file - — runs an empty script
- AI-generated content: Studio's own AI tools (
apps/cli/ai/tools.ts) go through sendWpCliCommand, which is the broken path. Anything an agent pipes as content is lost.
The silent-failure mode is what makes this particularly dangerous — a user running gunzip -c prod.sql.gz | studio wp db query against a newly cloned site gets no indication that the dump wasn't applied. They open the site, it looks wrong, they re-run, same result, spiral into debugging WP internals — when the actual answer is that their bytes were never delivered.
This also works correctly on native WordPress. Same wp-cli.phar, same Studio site folder, but invoked via the system PHP binary:
$ echo "real-wp-eval" | php /Users/chubes/.studio/server-files/wp-cli.phar \
--path=~/Studio/<site-name> \
eval 'echo "got: [" . file_get_contents("php://stdin") . "]\n";'
got: [real-wp-eval
]
Users migrating local dev workflows to Studio trip over this the first time they pipe anything at it.
Where the bytes die
I bisected the layers. There are three process/thread boundaries between the user's shell and the PHP runtime, and studio wp drops stdin at the very first one:
your shell pipe
│
▼
studio wp ← Node process A, holds your pipe as process.stdin
(apps/cli/commands/wp.ts)
│
│ never reads process.stdin ← DROP HERE (Studio-side, the bug)
│
▼ IPC message: { topic: 'wp-cli-command', data: { args } } (no stdin in payload)
│
wordpress-server-child ← Node process B, long-running daemon child, its own process.stdin
(apps/cli/wordpress-server-child.ts)
│
│ calls server.playground.cli(args)
│
▼ comlink → worker_threads
│
PlaygroundCliWorker ← Node worker thread C, hosts the PHP-WASM runtime
│
▼ php.cli(args) — API has no stdin option anyway
│
PHP-WASM ← Emscripten's default FS_stdin_getChar reads worker thread's fd 0,
which is not your shell pipe. Returns "" for php://stdin.
Proof it's Studio-side and not downstream:
| Path |
Stdin arrives? |
studio wp ... (daemon path, site running) |
❌ got: [] |
studio wp --php-version=8.2 ... (forces in-proc runWpCliCommand path) |
✅ got: [...] |
Bare php-wasm-cli (standalone Playground CLI, no Studio) |
✅ got: [...] |
Direct loadNodeRuntime + php.cli(...) in the same Node process as the pipe |
✅ got: [...] |
Only the daemon IPC path drops stdin. The WASM PHP runtime and Playground's php.cli() both behave correctly when host stdin is available to the hosting Node process.
Why the in-proc path works
When studio wp is invoked with a PHP version that differs from the site's configured version (or when the site isn't running), the code in apps/cli/commands/wp.ts falls through to runWpCliCommand (apps/cli/lib/run-wp-cli-command.ts), which calls loadNodeRuntime + php.cli(...) inside the studio wp Node process itself. That process still owns the user's pipe, so Emscripten's default FS_stdin_getChar — which under the hood does fs.readSync(process.stdin.fd, …) — ends up reading your bytes. Implicit and accidental, but it works.
The daemon path doesn't get this for free, because the WASM runtime lives in a worker thread of a long-running daemon-spawned child process. No stdin inheritance chain reaches it.
Relationship to the Playground API
There's a complementary gap upstream: @php-wasm/universal's PHP.cli(argv, options) has no stdin option, so even if Studio drained process.stdin in studio wp, there is currently no public API to hand those bytes to PHP. Filed upstream: WordPress/wordpress-playground#3519.
Ideal fix sequence:
- Land the Playground API:
php.cli(argv, { stdin: Uint8Array | Buffer | ReadableStream | string }).
- Land the Studio consumer:
- In
apps/cli/commands/wp.ts, if !process.stdin.isTTY, drain process.stdin to a Buffer upfront.
- Extend the
wp-cli-command IPC payload schema (apps/cli/lib/types/wordpress-server-ipc.ts) to carry stdin?: string (base64) alongside args.
- In
apps/cli/wordpress-server-child.ts, decode and forward via server.playground.cli(args, { stdin }).
- Forward the same buffer in the in-proc
runWpCliCommand path for symmetry.
Studio-only workaround that sidesteps the Playground gap: force the in-proc path whenever stdin is non-TTY. Cost: a cold-start penalty (~1s) for stdin-piped commands instead of the warm daemon. Fine as a stopgap, not great as a long-term design — loses the warm-server benefit for exactly the commands most likely to be expensive (large pipes).
Environment
studio 1.7.9 on macOS 26.4.1 (ARM64)
- Node v24.13.1
- Confirmed present on Studio trunk — the code paths involved (
apps/cli/commands/wp.ts, apps/cli/lib/wordpress-server-manager.ts, apps/cli/wordpress-server-child.ts) do not read process.stdin anywhere in the wp-cli command flow, and the IPC payload schema for wp-cli-command has no stdin field.
Summary
studio wp <command>silently drops host stdin when the target site's WordPress server is running. Any wp-cli invocation that reads fromphp://stdinsees an empty string; there is no error, no warning, and the command returns a 0 exit code — so callers can believe their operation succeeded when in fact no user data reached PHP at all.Canonical reproduction
Expected:
got: [hello-from-host-stdin].100% reproducible on a fresh shell against any running Studio site. The same command works correctly when the site is not running (Studio falls through to a different code path — see Why below).
Impact
Breaks the entire class of wp-cli workflows that use stdin. This is idiomatic wp-cli usage, not an edge case:
gunzip -c backup.sql.gz | studio wp db query— appears to succeed, imports nothingstudio wp db query < file.sql— no rows processedstudio wp post create --post_content=-(wp-cli's-sentinel) — creates an empty postcat export.xml | studio wp import -— imports nothingcat script.php | studio wp eval-file -— runs an empty scriptapps/cli/ai/tools.ts) go throughsendWpCliCommand, which is the broken path. Anything an agent pipes as content is lost.The silent-failure mode is what makes this particularly dangerous — a user running
gunzip -c prod.sql.gz | studio wp db queryagainst a newly cloned site gets no indication that the dump wasn't applied. They open the site, it looks wrong, they re-run, same result, spiral into debugging WP internals — when the actual answer is that their bytes were never delivered.This also works correctly on native WordPress. Same
wp-cli.phar, same Studio site folder, but invoked via the system PHP binary:Users migrating local dev workflows to Studio trip over this the first time they pipe anything at it.
Where the bytes die
I bisected the layers. There are three process/thread boundaries between the user's shell and the PHP runtime, and
studio wpdrops stdin at the very first one:Proof it's Studio-side and not downstream:
studio wp ...(daemon path, site running)got: []studio wp --php-version=8.2 ...(forces in-procrunWpCliCommandpath)got: [...]php-wasm-cli(standalone Playground CLI, no Studio)got: [...]loadNodeRuntime+php.cli(...)in the same Node process as the pipegot: [...]Only the daemon IPC path drops stdin. The WASM PHP runtime and Playground's
php.cli()both behave correctly when host stdin is available to the hosting Node process.Why the in-proc path works
When
studio wpis invoked with a PHP version that differs from the site's configured version (or when the site isn't running), the code inapps/cli/commands/wp.tsfalls through torunWpCliCommand(apps/cli/lib/run-wp-cli-command.ts), which callsloadNodeRuntime+php.cli(...)inside thestudio wpNode process itself. That process still owns the user's pipe, so Emscripten's defaultFS_stdin_getChar— which under the hood doesfs.readSync(process.stdin.fd, …)— ends up reading your bytes. Implicit and accidental, but it works.The daemon path doesn't get this for free, because the WASM runtime lives in a worker thread of a long-running daemon-spawned child process. No stdin inheritance chain reaches it.
Relationship to the Playground API
There's a complementary gap upstream:
@php-wasm/universal'sPHP.cli(argv, options)has nostdinoption, so even if Studio drainedprocess.stdininstudio wp, there is currently no public API to hand those bytes to PHP. Filed upstream: WordPress/wordpress-playground#3519.Ideal fix sequence:
php.cli(argv, { stdin: Uint8Array | Buffer | ReadableStream | string }).apps/cli/commands/wp.ts, if!process.stdin.isTTY, drainprocess.stdinto aBufferupfront.wp-cli-commandIPC payload schema (apps/cli/lib/types/wordpress-server-ipc.ts) to carrystdin?: string(base64) alongsideargs.apps/cli/wordpress-server-child.ts, decode and forward viaserver.playground.cli(args, { stdin }).runWpCliCommandpath for symmetry.Studio-only workaround that sidesteps the Playground gap: force the in-proc path whenever stdin is non-TTY. Cost: a cold-start penalty (~1s) for stdin-piped commands instead of the warm daemon. Fine as a stopgap, not great as a long-term design — loses the warm-server benefit for exactly the commands most likely to be expensive (large pipes).
Environment
studio1.7.9 on macOS 26.4.1 (ARM64)apps/cli/commands/wp.ts,apps/cli/lib/wordpress-server-manager.ts,apps/cli/wordpress-server-child.ts) do not readprocess.stdinanywhere in the wp-cli command flow, and the IPC payload schema forwp-cli-commandhas no stdin field.