Automattic / Automattic/studio
`studio wp` silently drops host stdin when the site is running (breaks `wp db query < file.sql`, `wp post create --content=-`, etc.)
- Dominant language
- TypeScript
- Stars
- 517
- Forks
- 95
- Avg merge
- 1d 22h
- Merged PRs (30d)
- 162
Description
## Summary
`studio wp ` 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
```sh
$ 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:
```sh
$ echo "real-wp-eval" | php /Users/chubes/.studio/server-files/wp-cli.phar \
--path=~/Studio/ \
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:
1. Land the Playground API: `php.cli(argv, { stdin: Uint8Array | Buffer | ReadableStream | string })`.
2. 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.
Contributor guide
Assessment
This issue has not been assessed yet.