fix(terminal): retire the compound-background rewriter - #68948
Sora-bluesky wants to merge 6 commits into
Conversation
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. |
61cf5e6 to
e29d185
Compare
|
suggesting changes The valid
Security evidence:
Because the submitted head and current GitHub Signed: GPT-5.6-sol-xhigh in Codex |
|
Confirmed and fixed in 652f82cd0. You're right: the lookahead skipped a same-line trailing command but still rewrote when The rewrite is now restricted to proven-valid boundaries: end-of-string and newline only. Every other continuation right after the Added a negative test that those malformed continuations pass through unchanged, plus an executable 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. |
|
suggesting changes
No change is needed for the separate newline-boundary concern: rewriting 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:
Signed: GPT-5.6-sol-xhigh in Codex |
|
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. The rewriter turns the last line into 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 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. |
652f82c to
cd3986a
Compare
|
@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, So the push does what you actually asked for, in the strongest form: commands are not rewritten at all anymore. 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 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. |
cd3986a to
566c066
Compare
c60877e to
f62c486
Compare
f62c486 to
0374b8b
Compare
0374b8b to
d7ce79b
Compare
d7ce79b to
25114db
Compare
25114db to
305ea4d
Compare
…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>
|
@codex review |
305ea4d to
1b07e2e
Compare
…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>
…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>
1b07e2e to
b86c554
Compare
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_backgroundentirely.Why removal instead of another guard
The rewriter turns
A && B &intoA && { 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: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 nobash -ncheck 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_loopkept the old blockingread1()there. That mattered for retirement: on Windows the rewriter was incidentally load-bearing for one shape. ForA && B >/dev/null 2>&1 &(the realisticcd /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.exitedflips at ~0.7s. Without it, the reader stays parked until B dies, andnotify_on_completenever fires for a long-lived server.So the Windows branch of
_reader_loopnow mirrors the POSIX select() loop onPeekNamedPipe(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 flipsession.exitedat ~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/_winapiand 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_backgroundand itsrewrite_compound_backgroundparameter are gone from both call sites (BaseEnvironment.execute,ProcessRegistry.spawn_local).tests/tools/test_terminal_compound_background.pyat two depths: a seam probe (nothing may transform the command before_wrap_command), and asubprocess.Popenargv capture on the concrete local backends asserting every corruption-class input reaches bash byte-identical.spawn_localis checked by full argv equality,executeby the exacteval '<escaped>'payload. A rewrite reintroduced inside_wrap_commandor_run_bashfails these.Evidence
process_registry.pyreverted, 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