fix(terminal): decode non-UTF-8 command output in the host codepage - #89465
fix(terminal): decode non-UTF-8 command output in the host codepage#89465jackulau wants to merge 1 commit into
Conversation
|
The red check on this PR ( The failure, from job 95864357118: Why it is not mine:
The cause is a bare Happy to rebase this PR once that lands, or a re-run of the slice should also come back green. |
d880013 to
338eb03
Compare
Git Bash forwards a native Windows child's bytes without transcoding them, so on a CJK install a powershell.exe error arrives as GBK inside a stream whose MSYS tools write UTF-8. The drain in _wait_for_process decoded everything strictly as UTF-8 with errors="replace", which turned every one of those bytes into U+FFFD at decode time - the bytes were gone and the agent read a wall of replacement characters instead of the error message (NousResearch#89442). Retry a line that strict UTF-8 rejects in the host's locale encoding. Output that is valid UTF-8 decodes exactly as before, so this can recover text and cannot corrupt text that was already fine. - _Utf8WithFallbackDecoder: line-buffered, because legacy multi-byte encodings are not self-synchronising and a fallback decode over an arbitrary 4096-byte read can split a two-byte GBK character. A unit containing NUL stays on the replacement path, so binary output does not come back as mojibake. - _system_ansi_encoding: locale.getencoding(), not getpreferredencoding(), which reports utf-8 under Python UTF-8 mode and says nothing about what a native child writes. Returns None on a UTF-8 host, making the whole path a no-op there. - BaseEnvironment._output_fallback_encoding defaults to None, so remote backends keep the old decoder rather than guessing a container's codepage from this host. Only LocalEnvironment overrides it, and only on Windows. Refs NousResearch#89442
338eb03 to
2c582c1
Compare
|
Rebased onto For anyone reading the previous red: the failure was not in this PR's area. Both attempts on the last run failed the same unrelated test: That test waits for the goal continuation with a single fixed 50 ms sleep while the enqueue is done by a task the hook spawns, so a loaded runner loses the race and reports No functional change in this push - same three files, same 485/2 diff, just re-parented. |
What does this PR do?
The terminal tool's output drain decoded every byte strictly as UTF-8 with
errors="replace". Git Bash is a byte pipe rather than a transcoder, so on a CJK Windows install apowershell.exeerror arrives as GBK inside a stream whose MSYS tools write UTF-8 — andreplaceis applied at decode time, so those bytes are gone. The agent reads a wall of U+FFFD instead of the error message it needs.This retries a line that strict UTF-8 rejects in the host's locale encoding.
The invariant that makes it safe: output that is valid UTF-8 decodes exactly as it did before. The fallback is only ever consulted for byte sequences UTF-8 rejects, which are already lost to U+FFFD today. So this can recover text and cannot corrupt text that was previously fine —
test_a_pure_utf8_stream_matches_the_old_decoder_exactlyasserts byte-for-byte agreement with the decoder it replaces.Related Issue
Refs #89442
One correction to that report, because it points at the wrong lines and the difference is load-bearing.
The issue's root cause is that
text=True, encoding="utf-8"onPopendecodes at the pipe level and "the original bytes are discarded". That is not what happens on this path._wait_for_processresolvesproc.stdout.fileno()andos.read()s the descriptor directly, bypassing theTextIOWrapperentirely — the raw bytes arrive intact, and the decision is made by the drain's owncodecs.getincrementaldecoder("utf-8")(errors="replace")a few lines later. The suggested remedy ("collect raw bytes, then decode with sniffing") is therefore already half-built in-tree; only the decoder choice was hardcoded.That makes this a much smaller change than the issue implies: no
Popenargument changes, nochcp, no shell wrapping. The three sites named in the issue (local.py:863,:929, and_popen_bash) are the Mandatory-ASLR probe, the bash-startup probe, and the SDK-backend spawn helper — none of them is the tool output path, and all are left alone.Type of Change
Changes Made
tools/environments/base.py_Utf8WithFallbackDecoder— samedecode(chunk, final=False)shape as the incremental decoder it stands in for. Tries strict UTF-8 first; only onUnicodeDecodeErrordoes it retry in the fallback; anything that fails both ends on the originalerrors="replace"path._system_ansi_encoding()—locale.getencoding(), canonicalised throughcodecs.lookup, returningNonewhen it is already UTF-8.BaseEnvironment._output_fallback_encoding()— new hook, returnsNone._wait_for_process— picks the decoder from that hook.Nonekeeps the exact previous object, so the untouched case is untouched by construction rather than by inspection.tools/environments/local.pyLocalEnvironment._output_fallback_encoding()— the host codepage, on Windows only.Three judgment calls, stated rather than buried
Decode per line, not per read. Legacy multi-byte encodings are not self-synchronising: GBK is two bytes per character with no lead/continuation distinction, so a fallback decode over an arbitrary 4096-byte read boundary can split a character and produce mojibake. A line is the unit that has a single origin — one program wrote it. This costs nothing in latency because
_wait_for_processrenders the collected output only after the drain thread joins; nothing observes the stream mid-command. An unterminated buffer is flushed at 32 KiB, and a bare CR is a boundary too so progress output does not accumulate.Opt-in per backend, defaulting to off. The hook returns
NoneonBaseEnvironment, so docker / ssh / daytona / modal keep the old decoder. Their bytes come from a container or a remote host whose codepage this process cannot observe, and answering from the local locale would be a fabricated answer. OnlyLocalEnvironmentoverrides it, because there the writer really is a child of this process. It is further gated on Windows: on POSIX the shell and its children agree on the locale, so a non-UTF-8 line there is far likelier to be binary than to be locale text.A unit containing NUL stays on the replacement path. This is the one place the change could plausibly make something worse. Legacy codepages are byte-dense — cp1252 decodes almost anything — so without a guard,
catof a binary file would come back as mojibake instead of the U+FFFD it produces today. No text encoding in real use emits an interior NUL, so the guard is free on the case that matters. It is not a complete binary detector, and I have not tried to make it one: the honest scope is "recover text that was destroyed", not "classify every stream".How to Test
pytest tests/tools/test_terminal_ansi_codepage_fallback.py -q # 32 passedBehavioural throughout — real
os.pipe()file descriptors driven through_wait_for_processitself for the wiring, and realbytes/codecsfor the decoder. No mocking at the boundary under test.Mutation proof — every property is independently load-bearing:
test_a_backend_with_a_fallback_recovers_the_messagetest_a_pure_utf8_stream_matches_the_old_decoder_exactlytest_binary_with_a_nul_byte_keeps_the_replacement_behaviourTestChunkBoundariesandTestBufferingContractgroupsNone-on-UTF-8-host short circuittest_local_on_a_utf8_windows_host_has_no_fallbackLocalEnvironment's Windows gatetest_local_has_no_fallback_off_windowstest_a_backend_without_one_is_unchangedBaseline — every environment/terminal test file (
tests/tools/test_base_environment.py,test_local_*.py,test_terminal_*.py,test_ssh_environment.py,test_docker_environment.py,test_daytona_environment.py,test_managed_modal_environment.py,test_approved_command_clean_slate.py),-q -p no:randomly, run serially, with and without the change:34 failed, 443 passed, 13 skipped34 failed, 475 passed, 13 skippedThe failing sets are identical — I diffed the two
FAILEDlists and they match line for line. They are pre-existing on this machine and unrelated: the docker/daytona/ssh suites want daemons and remote hosts that are not present, and several terminal tests assume a POSIX shell. The delta is exactly the 32 new tests.Overlap with open PRs
Nothing open touches this decoder. The neighbours are worth naming because the topic looks crowded from a title search:
tools/browser_use_cli.py; neither touchestools/environments/.read_filetools/file_operations, deciding whether a file is binary. This decides how a pipe is decoded. No shared code.acp_adapter/and two test files only.Checklist
Code
main)pytest tests/ -qwholesale: on Windowstests/hermes_cli/can't be collected (test_doctor_journal_modes.pycallsos.geteuid), so a full-suite number from here would be meaningless. CI runs it.Documentation & Housekeeping
_output_fallback_encodingis an internal hook on an existing abstract base, defaulting to the previous behaviourLocalEnvironment, a UTF-8 host getsNonefrom_system_ansi_encodingand therefore the byte-identical old decoder, and remote backends are excluded at the base class.scripts/check-windows-footguns.pypasses on all three files