mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
fix(windows): platform._syscmd_ver stub in bootstrap + PYTHONUTF8 in desktop backend env
Two gaps found auditing the decode-crash cluster: 1. suppress_platform_ver_console() only ran in hermes_cli.main processes; slash workers, tui_gateway/entry, run_agent, batch_runner, and cli.py import only hermes_bootstrap and were exposed to both the console flash and (on Python 3.11.0/3.11.1, which lack CPython's encoding='locale' fix) a UnicodeDecodeError inside platform.win32_ver() under PEP 540 — the crash #69413 reported. Move the stub into hermes_bootstrap so every entry point gets it; the _subprocess_compat copy stays for non-bootstrap callers. 2. The desktop Electron spawn built the backend env without PYTHONUTF8, so anything the Python child emitted before hermes_bootstrap ran (interpreter startup errors, pre-bootstrap tracebacks) decoded with the locale default. Re-port of PR #56499's env half (echoriver89) to backend-env.ts (original targeted the deleted backend-env.cjs); explicit user setting wins.
This commit is contained in:
parent
8516324f84
commit
0fb0ba475d
4 changed files with 111 additions and 0 deletions
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue