diff --git a/apps/desktop/electron/backend-env.test.ts b/apps/desktop/electron/backend-env.test.ts index e24a66ee396e..c42595a82472 100644 --- a/apps/desktop/electron/backend-env.test.ts +++ b/apps/desktop/electron/backend-env.test.ts @@ -68,6 +68,24 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () = assert.ok(env.PATH.includes('/opt/homebrew/bin')) }) +test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly', () => { + const defaulted = buildDesktopBackendEnv({ + hermesHome: '/Users/test/.hermes', + currentEnv: { PATH: '/usr/bin' }, + platform: 'darwin', + pathModule: path.posix + }) + assert.equal(defaulted.PYTHONUTF8, '1') + + const optedOut = buildDesktopBackendEnv({ + hermesHome: '/Users/test/.hermes', + currentEnv: { PATH: '/usr/bin', PYTHONUTF8: '0' }, + platform: 'darwin', + pathModule: path.posix + }) + assert.equal(optedOut.PYTHONUTF8, '0') +}) + test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => { assert.equal( normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }), diff --git a/apps/desktop/electron/backend-env.ts b/apps/desktop/electron/backend-env.ts index a225e2381776..3db4a19d0341 100644 --- a/apps/desktop/electron/backend-env.ts +++ b/apps/desktop/electron/backend-env.ts @@ -104,6 +104,13 @@ function buildDesktopBackendEnv({ return { PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }), + // Force PEP 540 UTF-8 mode in the spawned Python backend so its stdio and + // subprocess defaults are UTF-8 even on non-UTF-8 Windows locales (GBK, + // cp1252, ...). hermes_bootstrap sets this inside the child too, but only + // after import — anything emitted earlier (interpreter startup errors, + // pre-bootstrap tracebacks) still decodes with the locale default without + // this. User's explicit setting wins. Re-port of PR #56499 (echoriver89). + PYTHONUTF8: currentEnv?.PYTHONUTF8 ?? '1', [key]: buildDesktopBackendPath({ hermesHome, venvRoot, diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py index ae23cc976296..c0622bb0d961 100644 --- a/hermes_bootstrap.py +++ b/hermes_bootstrap.py @@ -122,6 +122,49 @@ def apply_windows_utf8_bootstrap() -> bool: return True +def suppress_platform_ver_console() -> None: + """Stub ``platform._syscmd_ver`` on Windows — decode-crash + flash guard. + + CPython's ``platform.win32_ver()`` (reached via ``platform.uname()`` / + ``platform.platform()``, which the OpenAI SDK touches for its + platform headers) shells out ``cmd /c ver``. Two failure modes: + + - **Console flash**: the ``check_output(..., shell=True)`` call has no + ``CREATE_NO_WINDOW``, so a windowless parent (pythonw gateway, slash + workers, kanban workers) flashes a visible console per call. + - **UnicodeDecodeError on Python 3.11.0/3.11.1**: those micros lack + CPython's ``encoding="locale"`` fix (added 3.11.2), so under PEP 540 + UTF-8 mode (which we enable above) the ``ver`` output — OEM code page + bytes on localized Windows — is strict-utf-8 decoded and raises, + crashing ``platform.platform()`` in any process that inherits + ``PYTHONUTF8=1`` (issue #69413). + + Stubbing ``_syscmd_ver`` to return its inputs makes ``win32_ver()`` hit + its documented fallback and read the version from + ``sys.getwindowsversion()`` — same data, in-process, no subprocess. + Mirrors ``hermes_cli._subprocess_compat.suppress_platform_ver_console`` + (kept there for callers that don't import bootstrap); double + application is harmless. Lives here so EVERY entry point gets it — + ``tui_gateway/slash_worker.py``, ``tui_gateway/entry.py``, + ``run_agent.py``, ``batch_runner.py``, and ``cli.py`` import only + ``hermes_bootstrap``, never ``hermes_cli.main``. + """ + if not _IS_WINDOWS: + return + try: + import platform + + if hasattr(platform, "_syscmd_ver"): + def _quiet_syscmd_ver(system="", release="", version="", + supported_platforms=("win32", "win16", "dos")): + return system, release, version + + platform._syscmd_ver = _quiet_syscmd_ver + except Exception: + # Hardening only — never let it break an entry point. + pass + + def harden_import_path(src_root: str | None = None) -> None: """Stop a package in the current directory from shadowing Hermes modules. @@ -188,6 +231,7 @@ def activate_durable_lazy_target() -> None: # the very top of their module, before importing anything else. The # import side effect does the right thing. apply_windows_utf8_bootstrap() +suppress_platform_ver_console() # Activate the durable lazy-install target (immutable Docker images) so # packages installed into the data volume on a previous run are importable diff --git a/tests/test_hermes_bootstrap.py b/tests/test_hermes_bootstrap.py index 50a582bf998a..6c2ecaf425ee 100644 --- a/tests/test_hermes_bootstrap.py +++ b/tests/test_hermes_bootstrap.py @@ -396,3 +396,45 @@ class TestHardenImportPath: sys.path[:] = original if original_env is not None: os.environ["HERMES_PYTHON_SRC_ROOT"] = original_env + + +class TestSuppressPlatformVerConsole: + """suppress_platform_ver_console: stub applied on Windows, no-op on POSIX.""" + + def test_noop_on_posix(self, monkeypatch): + import platform + hb = _fresh_import() + original = getattr(platform, "_syscmd_ver", None) + monkeypatch.setattr(hb, "_IS_WINDOWS", False) + hb.suppress_platform_ver_console() + assert getattr(platform, "_syscmd_ver", None) is original + + def test_stub_applied_when_windows(self, monkeypatch): + import platform + hb = _fresh_import() + original = getattr(platform, "_syscmd_ver", None) + try: + monkeypatch.setattr(hb, "_IS_WINDOWS", True) + hb.suppress_platform_ver_console() + stubbed = platform._syscmd_ver + assert stubbed is not original + # Stub returns its inputs — win32_ver()'s documented fallback path. + assert stubbed("s", "r", "v") == ("s", "r", "v") + # No-arg call (how Lib/platform.py invokes it in the fallback + # probe) must not raise — the rejected PR #69522 wrapper + # TypeError'd here. + assert stubbed() == ("", "", "") + finally: + if original is not None: + platform._syscmd_ver = original + + def test_never_raises(self, monkeypatch): + hb = _fresh_import() + monkeypatch.setattr(hb, "_IS_WINDOWS", True) + import platform + original = platform._syscmd_ver + try: + monkeypatch.delattr(platform, "_syscmd_ver") + hb.suppress_platform_ver_console() # hasattr guard → silent no-op + finally: + platform._syscmd_ver = original