fix: send redirections and expansions to the real shell (#46) - #103
Conversation
Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: #46
Issue: Git push commands with 2>&1 redirection were returning exit code 0 with empty output instead of proper error codes and messages, causing silent failures in CI/CD pipelines and repository creation scripts. Root Cause: Commands containing shell features like 2>&1 redirection were incorrectly parsed by the virtual command system. The parser would identify 'cd' as a virtual command and pass shell operators (&&, 2>&1) as arguments, causing the rest of the command (git push) to be ignored. Solution: - Add needsRealShell() check before virtual command execution - When a command requires real shell features, bypass virtual commands - Ensure commands with redirections execute in actual shell environment Changes: - Modified ProcessRunner._doStartAsync() to check needsRealShell() first - Added comprehensive test suite for git push scenarios - Added debug examples for reproducing and analyzing the issue Tests: All existing tests pass, new tests verify the fix works correctly. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
|
We need to get latest changes from default branch. We should double check more cases similar like this, make sure we have test coverage similar to all our competitors, and select behavior closer to how it would behave in sh scripts or with least surprise based on best practices from competitors. If there multiple options we should allow to configure, and use closer to sh behavior by default. |
|
🤖 AI Work Session Started Starting automated work session at 2026-09-05T09:43:11.989Z The PR has been converted to draft mode while work is in progress. This comment marks the beginning of an AI work session. Please wait for the session to finish, and provide your feedback. |
Resolve the repository restructure: src/ and tests/ and examples/ moved under js/, so the issue #46 work moves with them. The src/$.mjs edit from the old layout is dropped here; main already routes '2>&1' to the real shell via needsRealShell(), and the remaining gap is re-fixed on top of the new layout.
needsRealShell() was only consulted when the command also contained one of `&&`, `||`, `;`, `&` or `(`, and redirection characters are not part of that operator set. Any command whose first word is a built-in was therefore dispatched in-process with the operators left in the argument list, which is split on whitespace: `echo hello > out.txt` printed `hello > out.txt` and wrote no file, and `git push origin main 2>&1` reported exit code 0 with empty output even when the push had failed. The tell was that `echo $(echo hi)` worked while `echo $HOME` did not, purely because `(` happens to be in hasShellOperators(). - js: ask needsRealShell() unconditionally in handleShellMode(). - js: treat unquoted `<` and `>` as unsupported features, which covers `>`, `>>`, `2>`, `&>`, `>&`, `<`, `<<` and `<<<` in one rule. - rust: run the real-shell check before virtual command dispatch, and match the same character set in needs_real_shell(). Both new test files compare every case against /bin/sh, which is the contract, and cover the quoted forms (`echo "a > b"`) so the fix does not over-reach. Removes the stale examples and test written against the pre-monorepo layout; the old git-push test also required network access.
|
Addressed the review feedback: "We need to get latest changes from default branch." — "We should double check more cases similar like this" — the reported "make sure we have test coverage similar to all our competitors" — 25 new JS tests and 4 new Rust tests. Rather than hand-writing expectations, every case runs the same command through "select behavior closer to how it would behave in sh scripts ... use closer to sh behavior by default" — the fix routes redirections and expansions to the system shell rather than reimplementing them in the built-in path. Partial reimplementation of shell semantics is exactly what produced this bug class. Divergences are now 0 of 22. "If there multiple options we should allow to configure" — I did not add a knob here, and want to flag the reasoning explicitly in case you disagree: for Reproduction harness: |
🤖 Solution Draft LogThis log file contains the complete execution trace of the AI solution draft process. 💰 Cost: $6.507652📊 Context and tokens usage:Claude Opus 5: (2 sub-sessions)
Total: (5.7K new + 149.1K cache writes + 7.1M cache reads) input tokens, 57.5K output tokens, $6.507652 cost 🤖 Models used:
📎 Log file uploaded as Gist (2927KB)Now working session is ended, feel free to review and add any feedback on the solution draft. |
🎉 Auto-mergedThis pull request has been automatically merged by hive-mind.
Auto-merged by hive-mind with --auto-merge flag |
Fixes #46.
The problem
git push origin main 2>&1could report exit code0with empty output while the push had actually failed. The report is a symptom of a broader class of bug: any redirection or expansion was silently swallowed whenever the command started with a built-in.Root cause
handleShellMode()injs/src/$.process-runner-execution.mjsonly askedneedsRealShell()when the command already contained a shell operator:hasShellOperators()recognises&&,||,;,&and(— redirection characters are not in that set. So a command whose first word is a built-in (echo,cat,true,ls,seq,basename, …) went to the in-process implementation, whose arguments come from splitting on whitespace.>andout.txtarrived as two ordinary arguments;2>&1arrived as one. Nothing wrote a file, nothing redirected, and the exit status came from the built-in rather than from the real command.The tell that this was accidental:
echo $(echo hi)behaved correctly whileecho $HOMEdid not — purely because(happens to be inhasShellOperators().Rust was worse:
ProcessRunnerdispatched to virtual commands before any real-shell check ran at all.The fix
Both implementations now decide "does this need a real shell?" independently of which operators happen to be present, and both recognise redirection:
js/src/$.process-runner-execution.mjs—needsRealShell(command)is consulted unconditionally.js/src/shell-parser.mjs— unquoted<and>join the unsupported-feature set. One rule covers>,>>,1>,2>,&>,>&,<,0<,<<and<<<; the existing2>/&>/<<prefix checks became redundant and were dropped. The scan is quote-aware, soecho "a > b"still prints the literal text.rust/src/lib.rs— the real-shell check runs before virtual dispatch.rust/src/shell_parser.rs—needs_real_shellmatches the same character set.Why route to the shell instead of implementing redirection in the built-ins
Per the review feedback on this PR — "select behavior closer to how it would behave in sh scripts or with least surprise based on best practices from competitors" —
/bin/shis the contract. Partially reimplementing shell semantics in the built-in path is precisely what produced this bug class, and for>/>>both candidate implementations produce byte-identical observable results, so there is no user-visible choice worth a new config knob. The existingshellOperatorsandenableVirtualCommands/disableVirtualCommandsoptions are unchanged.Reproducing
The harness runs 22 cases through both
/bin/shandcommand-streamand diffs stdout, exit code and the resulting directory contents. Before the fix: 16 divergences. After: 0.Tests
Every case in both new files is asserted against
/bin/shrather than against a hand-written expectation, so the tests encode the parity contract directly.js/tests/redirection-silent-failure.test.mjs— 25 tests: 18 redirection cases, 5 expansion cases ($HOME,*,~, backticks,$(...)), the two quoted cases that must not change (echo "a > b",echo 'a > b'), aneedsRealShellunit test, and an offlinegit pushregression test that pushes to a non-existent local path and asserts a non-zero code withfatal:on stdout.rust/tests/redirection_silent_failure.rs— 4 tests mirroring the same cases (#![cfg(unix)]), as required by.github/workflows/parity.yml.Both files were confirmed to fail without the fix (JS: 16 failures; Rust: 3 of 4) and pass with it. The full JS suite shows no regressions against a stashed baseline;
cargo testis green; ESLint, Prettier,cargo fmtandcargo clippy -D warningsare clean.Housekeeping
maininto the branch (resolves the reported conflicts — the branch predated thejs/+rust/monorepo restructure).js/examples/test-git-push-*.mjsscripts andjs/tests/git-push-silent-failure.test.mjs, written against the old flat layout; the old test also required network access.js/.changeset/issue-46-redirection-sh-parity.md(patch) andrust/changelog.d/20260905_095834_redirection_sh_parity.md(patch).