Skip to content

fix(terminal): retire the compound-background rewriter - #68948

Open
Sora-bluesky wants to merge 6 commits into
NousResearch:mainfrom
Sora-bluesky:fix/issue-68915
Open

Sora-bluesky wants to merge 6 commits into
NousResearch:mainfrom
Sora-bluesky:fix/issue-68915

Conversation

@Sora-bluesky

@Sora-bluesky Sora-bluesky commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Originally opened for #68915 (worker deadlock around &-backgrounded servers). That hang was since fixed at the process layer by #71008 and the issue is closed. This PR now does the opposite of its first version. Instead of patching the rewriter's scanner again, it extends the #71008 fix to Windows and then removes _rewrite_compound_background entirely.

Why removal instead of another guard

The rewriter turns A && B & into A && { B & } by textually scanning for top-level &&/||/&. Review here kept producing inputs where that scan turns valid bash into invalid bash, or worse, silently changes program data. Verified on current main by running the extracted rewriter directly:

echo `A && B` &                     -> unmatched backtick, parse error
echo ${x:-A&&B} &                   -> broken expansion
[[ -n x && -n y ]] &                -> broken conditional
echo $[1&&2] &                      -> broken legacy arithmetic
a[1&&2]=x &                         -> broken array subscript
heredoc body containing "A && B &"  -> payload data changed
$'...\' A && B &\n...'              -> ANSI-C string data changed
false && echo B &  ...  $?          -> observable exit status changed (0 -> 1)

I first tried the guard route: earlier versions of this branch bailed out on backticks, ${...}, and [[. Each guard surfaced the next corruption, and the last three classes above are not guardable at all. They are data changes, not syntax errors, so no bash -n check can catch them. Syntax created at runtime (alias expansion, eval) is out of reach of any pre-execution textual check. A rewriter that must enumerate bash grammar to stay safe has negative expected value once the hang it guarded is fixed elsewhere.

Commit 1: extend the child-exit-aware reader to Windows

The #71008 reader is select()-based, and select() does not work on pipe fds on Windows, so _reader_loop kept the old blocking read1() there. That mattered for retirement: on Windows the rewriter was incidentally load-bearing for one shape. For A && B >/dev/null 2>&1 & (the realistic cd /app && node server.js &>/tmp/srv.log &), raw bash backgrounds the whole (A && B) subshell, which keeps holding the reader's pipe while the redirected B runs. Measured on Windows 11: with the rewrite, session.exited flips at ~0.7s. Without it, the reader stays parked until B dies, and notify_on_complete never fires for a long-lived server.

So the Windows branch of _reader_loop now mirrors the POSIX select() loop on PeekNamedPipe (works on anonymous pipes): read only when bytes are reported, otherwise check the direct child and stop after the same short idle grace. Measured after the change: both the redirected and the unredirected background shape flip session.exited at ~1.2s. That is strictly better than the rewriter ever was on Windows, since the rewriter never helped the unredirected shape (the grandchild inherits the pipe either way).

The contract tests fake msvcrt/_winapi and flip _IS_WINDOWS, so Linux CI exercises the Windows branch instead of skipping it. The integration test runs the real pipeline on Windows with no poll()/wait() in the loop, pinning the autonomous-completion lifecycle itself.

Commit 2: retire the rewriter

  • _rewrite_compound_background and its rewrite_compound_background parameter are gone from both call sites (BaseEnvironment.execute, ProcessRegistry.spawn_local).
  • The retirement is pinned by tests/tools/test_terminal_compound_background.py at two depths: a seam probe (nothing may transform the command before _wrap_command), and a subprocess.Popen argv capture on the concrete local backends asserting every corruption-class input reaches bash byte-identical. spawn_local is checked by full argv equality, execute by the exact eval '<escaped>' payload. A rewrite reintroduced inside _wrap_command or _run_bash fails these.

Evidence

  • Fail-before, retirement suite: 35/35 fail on current main (rewriter present), 35/35 pass on this branch.
  • Fail-before, Windows reader: the three new reader tests fail with commit 1's process_registry.py reverted, pass with it.
  • tests/tools/test_process_registry.py + the retirement suite show the same 4 known Windows-baseline failures as clean main, nothing new.

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/tools Tool registry, model_tools, toolsets tool/terminal Terminal execution and process management needs-decision Awaiting maintainer decision before any implementation sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 21, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Related competing fixes for #68915: #41368 inserts a separator and #68935 also rewrites parenthesized subshells. This PR intentionally skips the same-line rewrite to preserve original background scheduling. Maintainer decision needed on the intended semantics.

@Sora-bluesky
Sora-bluesky force-pushed the fix/issue-68915 branch 4 times, most recently from 61cf5e6 to e29d185 Compare July 22, 2026 10:38
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The valid A && B & C case is now preserved, and the focused terminal/environment coverage passed, but the new lookahead still turns malformed shell into executable shell.

_rewrite_compound_background("A && B & | C") returns A && { B & } | C; bash -n rejects the original with status 2 but accepts the rewritten command with status 0. The same validity inversion occurs for &;, & &&, & ||, and a second &, yet the new test labels most of those shapes valid. Since this rewriter processes LLM-supplied commands immediately before Bash, that can run commands Bash would otherwise reject. Please restrict rewriting after the background operator to proven-valid boundaries, leave malformed continuations unchanged, and update the test and comment accordingly. The newline case may retain its existing rewrite coverage, but it should not be described as scheduling-preserving when another command follows.

Security evidence:

  • trust boundary: LLM-supplied shell text is rewritten immediately before Bash parses and executes it.
  • source/sink/invariant: the lookahead controls whether an asynchronous AND/OR list is rewritten; it must not turn a Bash parse error into a different valid pipeline or list.
  • current-main reproduction: current main emits invalid output for the valid A && B & C case and also converts malformed control-operator continuations into valid shell.
  • PR-head or patch-replay validation: the patch replay preserves the valid same-line trailing-command case, but changes A && B & | C and A && B & & C from bash -n status 2 to status 0.
  • positive/negative cases: valid same-line input, clean end boundaries, comments, redirects, quotes, parentheses, arithmetic, heredocs, loops, and multiple statements passed, while malformed &;, & &&, & ||, & |, and & & cases violate the syntax-validity invariant.
  • residual bypass search: the first-character whitelist for ;, &, and | admits the malformed separator variants above; an unmatched } remains invalid but is still unnecessarily rewritten.
  • reviewer validation: the changed state machine, execution call site, focused tests, exact imported replay module, Bash syntax probes, and CodeRabbit findings were checked.

Because the submitted head and current GitHub main no longer share a Git merge base, I validated and externally reviewed the exact production/test patch replayed onto current main; the contributor mapping was already present there with identical content.

Signed: GPT-5.6-sol-xhigh in Codex

@Sora-bluesky

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in 652f82cd0. You're right: the lookahead skipped a same-line trailing command but still rewrote when ;, &, or | followed the &, and those are already parse errors. bash -n accepts A && { B & };C (status 0) while rejecting the original (status 2).

The rewrite is now restricted to proven-valid boundaries: end-of-string and newline only. Every other continuation right after the & (&;, & ;, & |, &|, & &, & &&, & ||, & }) is left byte-for-byte unchanged, so bash still rejects it.

Added a negative test that those malformed continuations pass through unchanged, plus an executable bash -n test asserting the rewriter never flips a parse error to a valid pipeline. Both fail on the pre-fix code.

Thanks for the security-framed review. The invariant ("must not turn a parse error into a valid pipeline") and the negative-case enumeration are exactly the checks I had missed.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

tools/terminal_tool.py::_rewrite_compound_background still treats operators inside legacy backtick command substitutions as top-level shell syntax. For example, valid Bash echo `A && B` & is rewritten to echo `A && { B` & }; bash -n returns 0 for the original and 2 for the rewritten output. Because BaseEnvironment.execute applies this transformation immediately before Bash execution, this is another command-validity corruption in the same rewrite boundary. Please conservatively leave commands containing unhandled backticks unchanged, or tokenize those substitutions correctly, and add a regression containing && inside a backtick substitution.

No change is needed for the separate newline-boundary concern: rewriting A && B &\nC is the documented and tested behavior that foregrounds A while backgrounding only B; preserving the original whole-compound scheduling would retain the process-leak defect.

The intended EOF/newline rewrites and the malformed- or same-line-continuation preservation otherwise remain covered by the focused suite and direct Bash syntax probes.

Security evidence:

  • trust boundary: LLM-supplied shell text is transformed by _rewrite_compound_background in BaseEnvironment.execute immediately before Bash parses and executes it.
  • source/sink/invariant: shell operators inside command substitutions must not become top-level rewrite markers, and the transformation must not turn valid Bash into invalid Bash.
  • current-main reproduction: base 4c9628eab5393e7561bbd2c1faaa1765fb14a5f9 rewrites valid echo `A && B` & into invalid echo `A && { B` & }.
  • PR-head or patch-replay validation: head 652f82cd0d09ea961e4a6d7e71006403597599fc produces the same corrupt rewrite; bash -n changes from status 0 to status 2.
  • positive/negative cases: EOF/newline rewrites and malformed or same-line continuations behave as intended, but a backtick substitution containing && is a concrete uncovered negative case.
  • residual bypass search: legacy backtick substitutions remain an unhandled scanner scope through which contained &&, ||, and & tokens can affect the outer rewrite.
  • reviewer validation: the exact head implementation and current-base implementation were invoked directly, and Bash syntax validation reproduced the valid-to-invalid transformation on both.

Signed: GPT-5.6-sol-xhigh in Codex

@Sora-bluesky

Copy link
Copy Markdown
Contributor Author

Following up on the corruption you flagged. I took the "never turn valid bash into invalid bash" invariant to an exhaustive check, and it doesn't hold for a textual rewrite in general. The backtick case was one instance of a broader problem.

Two layers to it. bash -n on the output isn't a sound check, because an unquoted here-doc body's command substitution is only parsed at execution, past bash -n. And even gating those out, runtime parsers (alias, eval, source, trap, nested bash -c) still slip through. A concrete valid-to-invalid case that carries no special character to gate on:

shopt -s expand_aliases
alias x=': #'
: && x &

The rewriter turns the last line into : && { x & }. At runtime the alias expands x to : #, the # comments out the injected }, and bash rejects it: bash -c returns 2 where the original returned 0. No pre-execution textual check can catch that, since the text that breaks it only exists after alias expansion.

On top of that, this function's own docstring notes the worker hang the PR targets (#68915) is already handled generically by the idle-after-exit drain timeout in tools/environments/base.py (#8340), independent of shell spelling. So the rewrite is really a best-effort reduction in subshell leakage for the common case, weighed against a rare but silent corruption risk on valid input.

Given that, I don't think a textual rewrite can be made sound here, and I'd rather surface it than keep patching one counterexample at a time. Happy to close this if you'd prefer, or leave it as a best-effort optimization with the limitation documented, your call on what's more useful for the codebase.

@alt-glitch alt-glitch removed comp/tools Tool registry, model_tools, toolsets sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 24, 2026
@Sora-bluesky Sora-bluesky changed the title fix(terminal): stop the background-compound rewrite from emitting invalid bash fix(terminal): retire the compound-background rewriter Jul 26, 2026
@Sora-bluesky

Copy link
Copy Markdown
Contributor Author

@egilewski This branch changed direction, and one correction first: when I wrote my last comment, the backtick bail with your requested regression was already implemented locally. I argued the general point and left that out. I should have pushed it then, or said so.

Then I tried to prove the bail route sound, and it failed my own tests. The bail catches your backtick case, ${...} and [[, but the same scanner still corrupts $[1&&2] and a[1&&2]=x, silently alters data in $'...' ANSI-C strings and heredoc bodies containing A && B &, and flips the observed $? of false && echo B & from 0 to 1. Those last three are data changes rather than parse errors, so bash -n can't catch them, and every guard added for one shape surfaced the next.

So the push does what you actually asked for, in the strongest form: commands are not rewritten at all anymore. _rewrite_compound_background is removed from both call sites. Your exact case echo `A && B` & is in the retirement suite, which pins byte-identical delivery at the final subprocess.Popen argv on the concrete backends, instead of only at a seam a future change could mock around.

One thing removal alone would have broken: on Windows the rewriter was accidentally load-bearing. The #71008 exit-aware reader is select()-based and select() doesn't work on pipe fds there, so for A && B >/dev/null 2>&1 & the raw form parks the reader on the (A && B) subshell's pipe until B dies, while the rewritten form doesn't. I measured both on Windows 11. The first commit closes that gap properly: the Windows reader now mirrors the select() loop on PeekNamedPipe, and both background shapes complete at ~1.2s, which the rewriter never managed for the unredirected one.

The alias counterexample from before still stands and is documented as the reason no textual pre-scan can be made sound. PR description is updated to match.

@alt-glitch alt-glitch added comp/tools Tool registry, model_tools, toolsets platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Jul 26, 2026
@alt-glitch alt-glitch added the backend/modal Modal.com cloud execution label Jul 29, 2026
@alt-glitch alt-glitch added comp/tools Tool registry, model_tools, toolsets and removed backend/modal Modal.com cloud execution labels Aug 27, 2026
@alt-glitch alt-glitch added the backend/modal Modal.com cloud execution label Aug 30, 2026
Sora-bluesky added a commit to Sora-bluesky/hermes-agent that referenced this pull request Sep 2, 2026
…ching its scanner again

_rewrite_compound_background rewrote `A && B &` into `A && { B & }` to stop
a backgrounded compound subshell from wedging the worker on its held stdout
pipe (NousResearch#68915, the vela/sal/combiagent leaks). Two things changed since:

1. NousResearch#71008 fixed the hang at the process layer (orphan-held stdout pipes),
   and NousResearch#68915 is closed. The rewrite no longer guards anything critical.
2. Review of the rewriter (NousResearch#68948) kept producing inputs where the textual
   scan turns valid bash into invalid bash or silently changes program data:
   backtick substitutions, ${...} expansions, [[ ]] conditionals, $[ ]
   legacy arithmetic, array subscripts, heredoc payloads, $'...' ANSI-C
   strings — and `false && echo B &` observably changes $? even in the
   intended case. Each scanner marker added for one class surfaced the
   next, and runtime-created syntax (alias, eval) is out of reach of any
   pre-execution textual check.

A transform that risks corrupting arbitrary LLM-generated commands to save
one leaked subshell is a bad trade, so this removes the rewrite at both
call sites (BaseEnvironment.execute, ProcessRegistry.spawn_local) along
with the execute() opt-out parameter. The regression suite now pins the
retirement: every previously-corrupted input class must reach bash
byte-identical.

What this gives up: a long-running `A && B &` leaks one subshell in
wait4 until B exits (resource hygiene, not a hang). If that cost matters,
the sound replacement is a parser-backed rewrite, not another marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Sora-bluesky

Copy link
Copy Markdown
Contributor Author

#101662 (62f2c82) patched _rewrite_compound_background in tools/terminal_tool.py and tests/tools/test_terminal_compound_background.py for #98222. This branch removes that helper; the Windows PeekNamedPipe reader stays. merge-tree onto 48a00349 is clean.

@Sora-bluesky

Copy link
Copy Markdown
Contributor Author

@codex review

Sora-bluesky added a commit to Sora-bluesky/hermes-agent that referenced this pull request Sep 13, 2026
…ching its scanner again

_rewrite_compound_background rewrote `A && B &` into `A && { B & }` to stop
a backgrounded compound subshell from wedging the worker on its held stdout
pipe (NousResearch#68915, the vela/sal/combiagent leaks). Two things changed since:

1. NousResearch#71008 fixed the hang at the process layer (orphan-held stdout pipes),
   and NousResearch#68915 is closed. The rewrite no longer guards anything critical.
2. Review of the rewriter (NousResearch#68948) kept producing inputs where the textual
   scan turns valid bash into invalid bash or silently changes program data:
   backtick substitutions, ${...} expansions, [[ ]] conditionals, $[ ]
   legacy arithmetic, array subscripts, heredoc payloads, $'...' ANSI-C
   strings — and `false && echo B &` observably changes $? even in the
   intended case. Each scanner marker added for one class surfaced the
   next, and runtime-created syntax (alias, eval) is out of reach of any
   pre-execution textual check.

A transform that risks corrupting arbitrary LLM-generated commands to save
one leaked subshell is a bad trade, so this removes the rewrite at both
call sites (BaseEnvironment.execute, ProcessRegistry.spawn_local) along
with the execute() opt-out parameter. The regression suite now pins the
retirement: every previously-corrupted input class must reach bash
byte-identical.

What this gives up: a long-running `A && B &` leaks one subshell in
wait4 until B exits (resource hygiene, not a hang). If that cost matters,
the sound replacement is a parser-backed rewrite, not another marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sora-bluesky and others added 6 commits September 16, 2026 14:40
…ching its scanner again

_rewrite_compound_background rewrote `A && B &` into `A && { B & }` to stop
a backgrounded compound subshell from wedging the worker on its held stdout
pipe (NousResearch#68915, the vela/sal/combiagent leaks). Two things changed since:

1. NousResearch#71008 fixed the hang at the process layer (orphan-held stdout pipes),
   and NousResearch#68915 is closed. The rewrite no longer guards anything critical.
2. Review of the rewriter (NousResearch#68948) kept producing inputs where the textual
   scan turns valid bash into invalid bash or silently changes program data:
   backtick substitutions, ${...} expansions, [[ ]] conditionals, $[ ]
   legacy arithmetic, array subscripts, heredoc payloads, $'...' ANSI-C
   strings — and `false && echo B &` observably changes $? even in the
   intended case. Each scanner marker added for one class surfaced the
   next, and runtime-created syntax (alias, eval) is out of reach of any
   pre-execution textual check.

A transform that risks corrupting arbitrary LLM-generated commands to save
one leaked subshell is a bad trade, so this removes the rewrite at both
call sites (BaseEnvironment.execute, ProcessRegistry.spawn_local) along
with the execute() opt-out parameter. The regression suite now pins the
retirement: every previously-corrupted input class must reach bash
byte-identical.

What this gives up: a long-running `A && B &` leaks one subshell in
wait4 until B exits (resource hygiene, not a hang). If that cost matters,
the sound replacement is a parser-backed rewrite, not another marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The NousResearch#71008 reader fix is select()-based and select() does not work on
pipe fds on Windows, so _reader_loop kept the historical blocking
read1() there. Any command that leaves a background grandchild holding
the stdout pipe parked the reader thread: session.exited never flipped
on its own and notify_on_complete never fired until the grandchild
died. Measured on Windows 11 with spawn_local("true && sleep 30
>/dev/null 2>&1 &"): exited stayed False for the full sleep; with this
change it flips at ~1.1s, right after the direct child exits.

PeekNamedPipe works on anonymous pipes and reports buffered bytes
without blocking, so the Windows branch now mirrors the POSIX select()
loop exactly: read only when bytes are available, otherwise check the
direct child and stop after the same short idle grace. Streams without
a real fd still use the blocking fallback.

The contract tests fake msvcrt/_winapi and flip _IS_WINDOWS so Linux
CI exercises the Windows branch instead of skipping it; the
integration test runs the real pipeline on Windows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…spawn path

Acceptance contract from NousResearch#98222: pin DockerEnvironment.execute so a
reintroduced rewrite option cannot ride its **kwargs forwarder, and
drive the code_kernel_remote spawn template through a dependency-light
fake of the shared execute() path asserting the un-rewritten command,
a real PID, persistent state on a second call, and a failed-spawn
negative control kept separate from reader lifecycle concerns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TIP execute() now forwards watch_interrupt_tid into _wait_for_process.
Probe stubs in the compound-background and remote-spawn tests must match that keyword so merged trees do not TypeError.
… path

Since 29b981c _release_finished_handles closes the child streams and only
suppresses OSError/ValueError, so the reader finish path raised AttributeError on
_FakeWinStdout and the three TestReaderLoopWindowsPeekBranch cases never reached
their assertions. The fake now records close() calls and the grandchild-held-pipe
case asserts the handle was released exactly once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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

backend/modal Modal.com cloud execution comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists platform/windows Native Windows-specific behavior or breakage sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows tool/terminal Terminal execution and process management type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants