Skip to content

feat(interpreter): expose stdinConnected on the command context - #448

Open
mutewinter wants to merge 3 commits into
vercel-labs:mainfrom
mutewinter:fix/stdin-connected-on-command-context
Open

mutewinter wants to merge 3 commits into
vercel-labs:mainfrom
mutewinter:fix/stdin-connected-on-command-context

Conversation

@mutewinter

@mutewinter mutewinter commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Problem

A custom command receives ctx.stdin as bytes and nothing about where they came from, so an empty pipe and no pipe are indistinguishable:

// a command that reads stdin when present and otherwise walks the cwd (ripgrep):
printf '' | mycmd   // ctx.stdin === EMPTY_BYTES
mycmd               // ctx.stdin === EMPTY_BYTES  -- same

A host tells them apart by fstat on fd 0. We embed just-bash and shell the real rg; guessing from the byte count meant false | rg PATTERN walked the task directory and returned its files as if they had been piped. It bit an agent: a failed producer upstream of rg turned into a 118KB dump of unrelated files presented as pipe content.

#401 fixes the same symptom for the bundled rg by carrying an @internal stdinProvided to a directly dispatched command. This one is the public form of that signal, for a custom command, and it holds through the compound commands, scripts, and nested shells a pipe can feed. Happy to rebase onto #401 if it lands first; the two overlap in pipeline-execution.ts, builtin-dispatch.ts, and types.ts.

Cause

RuntimeCommandContext carries stdin and no connectedness signal. The interpreter already knows it (stdinRedirected for a redirect; a pipeline hands every stage after the first the previous stage's stdout, empty or not) but drops it at the command boundary.

Underneath that, a pipeline stage after the first owned its stdin only when it was a simple command, group, subshell, or function. if, for, and case received no stdin at all, and so did source: printf 'a\n' | if true; then read x; fi and printf 'a\n' | source file left x empty where bash reads a.

Fix

Add ctx.stdinConnected: true when a redirect, a pipe, or an enclosing group's stdin is on fd 0, independent of whether any bytes arrived; false for a closed fd 0 (cmd 0<&-), and inherited rather than forced by cmd <&0. A scope carries closedness alongside ownership (groupStdinClosed beside groupStdin, and a stdinClosed parameter beside stdinOwned), so { cmd; } 0<&-, f 0<&-, eval … 0<&-, source file 0<&-, and ./script 0<&- read as EOF inside, as before, and report false; a scope's own open redirection (f() { cmd; } < file; f 0<&-) wins, as in bash.

Underneath, every pipeline stage after the first owns its stdin the way a redirect from an empty file does, and one helper installs that stream for all six compound commands, replacing the loop-only version. So the flag holds inside a group, subshell, function, if, for, while, until, case, executable script, sourced file, eval, or bash -c that a pipe feeds, and if, for, case, and source now read the pipe's bytes as bash does. bash -c forwards its stdin to the nested shell only when its own fd 0 is connected, so a bare bash -c cmd no longer hands every command inside it an empty stream to report. The value travels through command, builtin, and exec to the command they wrap.

Scope

Unchanged: ctx.stdin and every existing consumer; what eval, a function, and command own (a closed fd 0 still reads as EOF inside them, as before). Not addressed, deliberately: isatty-style distinctions beyond connectedness, since no in-tree command needs them.

Tests

custom-commands.test.ts: ctx.stdinConnected for a bare command, an empty pipe, a failed producer, an empty middle stage, a redirect from an empty file, an empty here-string, a pipe into each of group, subshell, function, if, for, C-style for, while, until, case, executable script, bash -c, command, exec, eval, and source, and a closed fd 0 reopened by an inner or a function's own redirection; false for a bare bash -c, a closed fd 0, cmd <&0 with nothing behind it, a script and an if with nothing to hand on, after exec 0<&-, and a closed fd 0 on a group, subshell, function call, function definition, eval, source, executable script, if, while, two scopes up, and inside a pipe, with the flag true again once that scope ends; reset once the pipeline is over; and the standalone-context path. Ten of the cases fail on the first commit of this PR and twelve more on the second.

pipeline-compound-stdin.comparison.test.ts: eleven fixtures recorded against GNU bash 3.2.57 for if, else, for, C-style for, and case bodies and a sourced file reading a pipe, a compound command's own redirect winning over the pipe, the pipe not reaching past the compound command, and a sourced file's reads advancing the shared position; seven fail without the second commit and one more without the third.

Unit suite 14,857 pass; comparison 883; spec 3,834. The three *.bundle.test.ts files (34 cases) fail identically without this change because they require a built dist/bundle/; verified by running the same files on the first commit in a separate worktree and diffing the case list, not inferred.


Authored with Claude Opus 5

A custom command received ctx.stdin as bytes and nothing about where they came from, so `false | cmd` and a bare `cmd` arrived identically. A command that reads stdin only when it has one (ripgrep, which walks the directory otherwise) had to guess from the byte count, and a producer that printed nothing became a directory walk.

ctx.stdinConnected says whether a pipe, a redirection, or an enclosing group's stdin is on the other end of fd 0, independent of whether bytes arrived. Underneath, every pipeline stage after the first now owns its stdin the way a redirection from an empty file does, so the flag holds inside a group, subshell, or function that a pipe feeds.
@vercel

vercel Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

@mutewinter is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@auto-maintain

auto-maintain Bot commented Sep 21, 2026

Copy link
Copy Markdown

🤖 auto-maintain review

Automated, advisory triage for @mutewinter's PR. Facts below are read from the GitHub API.

Check Result
Author's merged PRs (this repo) 10
Account established ✅ (age 5942d · 190 followers · 130 public repos)
Commits signed/verified ✅ 2/2
Changeset included ✅ (.changeset/stdin-connected.md)

Review panel: 🟡 medium highest severity

just-bash maintainer code review: 🟡 medium

The new signal is incorrect in sourced scripts and when a closed fd 0 crosses a nested execution boundary.

  • packages/just-bash/src/interpreter/builtin-dispatch.ts:839 — Closed fd 0 is propagated to nested functions/scripts/compounds only as `stdinOwned`; they install an empty `groupStdin`, so `f 0<&-` or `/script 0<&-` makes an inner custom command incorrectly report `stdinConnected: true`. Propagate connectedness separately from ownership.
  • packages/just-bash/src/interpreter/builtin-dispatch.ts:531 — The `source` builtin discards the command's stdin and ownership/connectedness state. Consequently, `printf '' | source /script` makes custom commands inside the sourced file report `stdinConnected: false` instead of inheriting the pipe.

General code review: 🟡 medium

The new signal works for direct commands but misreports closed stdin inside functions and compound scopes.

  • packages/just-bash/src/interpreter/builtin-dispatch.ts:550 — Closed fd 0 is lost across function boundaries: `stdinConnected` is not forwarded to `callFunction`; `f(){ probe; }; f 0<&-` therefore installs an empty owned stdin and `probe` incorrectly sees `stdinConnected === true`. Preserve connectedness separately from ownership through functions and other compound scopes.

Adversarial security: 🟡 medium

The new trust-boundary signal is incorrect for closed stdin propagated through nested execution scopes.

  • packages/just-bash/src/interpreter/subshell-group.ts:362 — Closed stdin is converted into an owned empty stream across scripts and compound scopes. For `/script 0<&-`, `stdinOwned` is true and this installs `groupStdin = ""`, so custom commands inside incorrectly receive `stdinConnected: true`. Functions, subshells, and compound commands have the same ownership/connectedness conflation. Preserve connectedness separately when crossing these boundaries.

Adversarial security (second opinion): 🟢 low

Stdin-connectedness plumbing is coherent and bash-aligned; no backdoors, no new I/O or network surface, no stdin leakage into nested shells, and the compound-in-pipeline semantics change is backed by real-bash comparison fixtures.

Standard Bash and host portability: 🟡 medium

The new connectedness signal is not preserved correctly through closed descriptors or the `source` builtin.

  • packages/just-bash/src/interpreter/interpreter.ts:908 — Closed fd 0 becomes falsely connected inside wrappers: `f 0<&-`, `eval ... 0<&-`, or an executable script installs an owned empty `groupStdin`, so nested custom commands report `stdinConnected=true` although Bash preserves the closed descriptor.
  • packages/just-bash/src/interpreter/builtin-dispatch.ts:531 — A piped `source` discards the pipeline stdin metadata. In `printf '' | source script`, custom commands within the sourced file report `stdinConnected=false`, although standard Bash leaves fd 0 attached to the pipe.

Posted by auto-maintain. This automated code review is advisory; a human maintainer makes the call.

…script, and nested shell

A pipeline stage after the first owned its stdin only when it was a simple command, group, subshell, or function. `if`, `for`, C-style `for`, `while`, `until`, and `case` dropped the ownership bit, and the first three dropped the bytes as well, so `printf 'a\n' | if true; then read x; fi` left `x` empty where bash reads `a`. One helper now installs the owned stream for all six, replacing the loop-only version.

An executable script installs the stream it was handed even when it is empty, so a custom command inside `printf '' | ./script` sees fd 0 connected. `bash -c` forwards its stdin to the nested shell only when its own fd 0 is connected, so a bare `bash -c cmd` no longer hands every command inside it an empty stream to report.

Connectedness is now computed beside ownership rather than derived from it: `cmd 0<&-` owns a closed fd 0 and reports false, and `cmd <&0` inherits the answer instead of forcing true. The value travels through `command`, `builtin`, and `exec` to the command they wrap.

Comparison fixtures recorded against GNU bash 3.2.57 pin the compound-command byte behavior; seven of nine fail without this change.
@mutewinter

Copy link
Copy Markdown
Contributor Author

All of it held, and the second commit takes each finding, with a test per case.

Compound commands. if, for, C-style for, and case were worse than the review said: as pipeline stages they received no stdin at all, bytes included, so printf 'a\n' | if true; then read x; fi left x empty where bash reads a. One helper now installs the owned stream for all six compound commands (the loop-only resolveLoopStdin is gone), and pipelineOwned replaces the byte-count test, so an empty pipe into a loop or conditional owns an empty stream. Nine comparison fixtures recorded against GNU bash 3.2.57 pin the byte behavior; seven fail without the change.

Executable scripts. executeUserScript installed the stream only when it had bytes. It now takes ownership alongside them, so printf '' | ./script hands the script's commands an empty stream to report.

bash -c. execWithInheritedStdin forwarded an empty buffer unconditionally, which the nested shell installed as groupStdin and every command inside then reported as connected. It forwards stdin only when the outer command's fd 0 is connected; a bare bash -c probe now reports false, and printf '' | bash -c probe still reports true.

0<&-. Connectedness is now computed beside ownership rather than from it, off the prepared fd 0 route: closed is false, an untouched fd 0 (or <&0, which was being forced to true) inherits the pipe, enclosing stream, or bytes, and anything else is true. Ownership is unchanged, so eval and a function called with 0<&- still read EOF rather than the enclosing stream. The value travels through command, builtin, and exec to the wrapped command. Ten of the new custom-commands.test.ts cases fail on the first commit.

One thing I did not change, and the body now says so: a closed fd 0 across a group or script boundary ({ probe; } 0<&-) reads as an owned empty stream inside, since ownership travels as a string and closedness would need its own state. Say the word and it is a follow-up.

I also found #401 after opening this, which fixes the same symptom for the bundled rg with an @internal stdinProvided on direct dispatch; the body now names it and the overlap, and I will rebase onto it if it lands first.

Unit 14,841 pass, comparison 881, spec 3,834; typecheck, lint, biome check, and knip clean.

…, and give source the stdin it was piped

A scope given a closed fd 0 by its caller (`{ cmd; } 0<&-`, `f 0<&-`, `eval … 0<&-`, `./script 0<&-`) installed an owned empty stream, and every command inside then reported fd 0 as connected. Closedness now travels beside ownership: `groupStdinClosed` beside `groupStdin` in the interpreter state, saved and restored at every install site, and a `stdinClosed` parameter beside `stdinOwned` on the command executors, functions, eval, source, and user scripts. A scope's own open redirection still wins over a closed fd 0 from its caller, as in bash; a scope's own `0<&-` closes it for everything inside.

`source` ran the file with no stdin at all, so `printf 'a\n' | source file` left a `read` inside empty where bash reads `a`. It now owns the stream it was piped or redirected the way `eval` does, restoring the enclosing one afterwards, installed only once the depth check has passed.

Comparison fixtures recorded against GNU bash 3.2.57 pin the sourced file reading the pipe and its reads advancing the shared position.
@mutewinter

Copy link
Copy Markdown
Contributor Author

Both findings on the second round held, and the third commit takes them.

A closed fd 0 across a boundary. I had scoped this out and the review was right not to let it go: { probe; } 0<&-, f 0<&-, eval probe 0<&-, and /script 0<&- all installed an owned empty stream and reported true inside. Closedness now travels beside ownership rather than being read off it: groupStdinClosed sits beside groupStdin in the state (saved and restored at every install site, the isolated-state snapshot included), and a stdinClosed parameter sits beside stdinOwned on executeCommand, the compound executors, callFunction, eval, source, and user scripts. A scope's own open redirection wins over a closed fd 0 from its caller (f() { probe; } < /empty; f 0<&- is true), a scope's own 0<&- closes it for everything inside, and the flag comes back once the scope ends. Twelve new cases in custom-commands.test.ts cover group, subshell, function call, function definition, eval, source, script, if, while, two scopes up, inside a pipe, and the reopen cases; all twelve fail on the second commit.

source. It was discarding stdin entirely, not just the metadata, so printf 'a\n' | source file left a read inside empty where bash reads a. It now owns the stream it was piped or redirected the way eval does and restores the enclosing one afterwards; the install happens after the depth check so a refused source leaves no state behind. Two comparison fixtures recorded against GNU bash 3.2.57 pin the sourced file reading the pipe and its reads advancing the shared position; the first fails on the second commit.

Unit 14,857 pass, comparison 883, spec 3,834; typecheck, lint, biome check, and knip clean.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant