Compare commits

...

2 Commits

Author SHA1 Message Date
Brooklyn Nicholson
0b5bb9f0b5 fix(windows): bootstrap utf-8 mode at entrypoints
Force UTF-8 defaults on legacy Windows by re-execing Hermes entrypoints with -X utf8, preventing locale codec crashes from implicit text encoding in file and stdio paths.
2026-05-07 22:43:17 -04:00
Brooklyn Nicholson
31e3bdee99 fix(windows): harden native CLI and TUI bootstrap
Handle native Windows dependency edge cases by avoiding npm.ps1 execution-policy failures, persisting managed Node resolution, and validating runtime imports per platform.
2026-05-07 22:04:42 -04:00
12 changed files with 603 additions and 34 deletions

View File

@@ -17,7 +17,15 @@ import asyncio
import logging
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
from utf8_bootstrap import ensure_windows_utf8_mode
# Ensure ACP stdio/file defaults are UTF-8 on legacy Windows builds.
ensure_windows_utf8_mode(
module="acp_adapter.entry",
entrypoint_markers=("hermes-acp", "entry.py"),
)
# Methods clients send as periodic liveness probes. They are not part of the

7
cli.py
View File

@@ -34,6 +34,8 @@ from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Optional
from utf8_bootstrap import ensure_windows_utf8_mode
logger = logging.getLogger(__name__)
# Suppress startup messages for clean CLI experience
@@ -12342,6 +12344,11 @@ def main(
"""
global _active_worktree
ensure_windows_utf8_mode(
module="cli",
entrypoint_markers=("hermes", "cli.py"),
)
# Signal to terminal_tool that we're in interactive mode
# This enables interactive sudo password prompts with timeout
os.environ["HERMES_INTERACTIVE"] = "1"

View File

@@ -15500,6 +15500,12 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
def main():
"""CLI entry point for the gateway."""
from utf8_bootstrap import ensure_windows_utf8_mode
ensure_windows_utf8_mode(
module="gateway.run",
entrypoint_markers=("gateway", "run.py"),
)
import argparse
parser = argparse.ArgumentParser(description="Hermes Gateway - Multi-platform messaging")

View File

@@ -43,12 +43,20 @@ Usage:
hermes claw migrate --dry-run # Preview migration without changes
"""
import os
import sys
from utf8_bootstrap import ensure_windows_utf8_mode
# Force UTF-8 defaults on Windows before any module-level file I/O.
ensure_windows_utf8_mode(
module="hermes_cli.main",
entrypoint_markers=("hermes", "main.py"),
)
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional

View File

@@ -154,7 +154,7 @@ hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils", "utf8_bootstrap"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*"]

View File

@@ -58,6 +58,7 @@ from datetime import datetime
from pathlib import Path
from hermes_constants import get_hermes_home
from utf8_bootstrap import ensure_windows_utf8_mode
_OPENAI_CLS_CACHE: Optional[type] = None
@@ -14483,6 +14484,11 @@ def main(
Toolset Examples:
- "research": Web search, extract, crawl + vision tools
"""
ensure_windows_utf8_mode(
module="run_agent",
entrypoint_markers=("hermes-agent", "run_agent.py"),
)
print("🤖 AI Agent with Tool Calling")
print("=" * 50)

View File

@@ -65,6 +65,108 @@ function Write-Err {
Write-Host "$Message" -ForegroundColor Red
}
function Add-UserPathEntry {
param(
[string]$CurrentPath,
[string]$Entry
)
if (-not $Entry) {
return $CurrentPath
}
$parts = @()
if ($CurrentPath) {
$parts = $CurrentPath -split ";" | Where-Object { $_ -and $_.Trim() }
}
$normalizedEntry = $Entry.Trim().TrimEnd("\")
foreach ($part in $parts) {
if ($part.Trim().TrimEnd("\") -ieq $normalizedEntry) {
return $CurrentPath
}
}
if ($CurrentPath) {
return "$Entry;$CurrentPath"
}
return $Entry
}
function Resolve-NpmInvocation {
# Prefer npm.cmd to avoid PowerShell execution-policy failures from npm.ps1.
$npmCmd = Get-Command npm.cmd -ErrorAction SilentlyContinue
if ($npmCmd -and $npmCmd.Source) {
return @($npmCmd.Source)
}
$npm = Get-Command npm -ErrorAction SilentlyContinue
if ($npm -and $npm.Source) {
if ($npm.Source -notmatch "\.ps1$") {
return @($npm.Source)
}
$candidateCmd = [System.IO.Path]::ChangeExtension($npm.Source, ".cmd")
if (Test-Path $candidateCmd) {
return @($candidateCmd)
}
}
# Last fallback for odd PATH setups: invoke npm-cli.js directly via node.
$node = Get-Command node -ErrorAction SilentlyContinue
if ($node -and $node.Source) {
$nodeDir = Split-Path -Parent $node.Source
$candidates = @(
(Join-Path $nodeDir "node_modules\npm\bin\npm-cli.js"),
"$HermesHome\node\node_modules\npm\bin\npm-cli.js"
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
return @($node.Source, $candidate)
}
}
}
return $null
}
function Invoke-NpmInstallSilent {
param(
[string]$WorkingDir
)
$npmInvocation = Resolve-NpmInvocation
if (-not $npmInvocation) {
throw "npm command not found in PATH"
}
Push-Location $WorkingDir
try {
$output = @()
if ($npmInvocation.Count -eq 1) {
$output = & $npmInvocation[0] install --silent 2>&1
} else {
$output = & $npmInvocation[0] $npmInvocation[1] install --silent 2>&1
}
if ($LASTEXITCODE -ne 0) {
$lastLine = ""
if ($output) {
$lines = @($output | ForEach-Object { "$_" } | Where-Object { $_ })
if ($lines.Count -gt 0) {
$lastLine = $lines[-1]
}
}
if ($lastLine) {
throw "npm install exited with code $LASTEXITCODE: $lastLine"
}
throw "npm install exited with code $LASTEXITCODE"
}
} finally {
Pop-Location
}
}
# ============================================================================
# Dependency checks
# ============================================================================
@@ -550,11 +652,21 @@ function Install-Dependencies {
$env:VIRTUAL_ENV = "$InstallDir\venv"
}
# Install main package with all extras
try {
& $UvCmd pip install -e ".[all]" 2>&1 | Out-Null
} catch {
& $UvCmd pip install -e "." | Out-Null
# Install main package with all extras first. If that fails (for example
# due to an optional extra on this machine), fall back to the minimum
# dependency profile required for native Windows CLI + TUI operation.
& $UvCmd pip install -e ".[all]" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Warn "Full extras install failed. Retrying with Windows CLI/TUI dependency set..."
& $UvCmd pip install -e ".[pty,mcp,honcho,acp]" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Warn "Windows CLI/TUI extras install failed. Retrying with base package..."
& $UvCmd pip install -e "." 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Pop-Location
throw "Failed to install Hermes Python dependencies."
}
}
}
Write-Success "Main package installed"
@@ -586,20 +698,35 @@ function Set-PathVariable {
$hermesBin = "$InstallDir\venv\Scripts"
}
# Add the venv Scripts dir to user PATH so hermes is globally available
# On Windows, the hermes.exe in venv\Scripts\ has the venv Python baked in
# Add required bins to user PATH so hermes and --tui dependencies persist
# across new terminal sessions.
# On Windows, the hermes.exe in venv\Scripts\ has the venv Python baked in.
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($currentPath -notlike "*$hermesBin*") {
[Environment]::SetEnvironmentVariable(
"Path",
"$hermesBin;$currentPath",
"User"
)
$newPath = Add-UserPathEntry -CurrentPath $currentPath -Entry $hermesBin
if ($newPath -ne $currentPath) {
Write-Success "Added to user PATH: $hermesBin"
} else {
Write-Info "PATH already configured"
Write-Info "PATH already includes: $hermesBin"
}
$managedNodeDir = "$HermesHome\node"
$managedNodeExe = "$managedNodeDir\node.exe"
if (Test-Path $managedNodeExe) {
$pathWithNode = Add-UserPathEntry -CurrentPath $newPath -Entry $managedNodeDir
if ($pathWithNode -ne $newPath) {
Write-Success "Added managed Node.js to user PATH: $managedNodeDir"
} else {
Write-Info "PATH already includes managed Node.js"
}
$newPath = $pathWithNode
# Hint hermes_cli.main._make_tui_argv() where node lives when a managed
# install is used (it still prefers PATH when available).
[Environment]::SetEnvironmentVariable("HERMES_NODE", $managedNodeExe, "User")
$env:HERMES_NODE = $managedNodeExe
}
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")
# Set HERMES_HOME so the Python code finds config/data in the right place.
# Only needed on Windows where we install to %LOCALAPPDATA%\hermes instead
@@ -612,7 +739,10 @@ function Set-PathVariable {
$env:HERMES_HOME = $HermesHome
# Update current session
$env:Path = "$hermesBin;$env:Path"
$env:Path = Add-UserPathEntry -CurrentPath $env:Path -Entry $hermesBin
if (Test-Path "$HermesHome\node\node.exe") {
$env:Path = Add-UserPathEntry -CurrentPath $env:Path -Entry "$HermesHome\node"
}
Write-Success "hermes command ready"
}
@@ -708,16 +838,14 @@ function Install-NodeDeps {
Write-Info "Skipping Node.js dependencies (Node not installed)"
return
}
Push-Location $InstallDir
if (Test-Path "package.json") {
if (Test-Path "$InstallDir\package.json") {
Write-Info "Installing Node.js dependencies (browser tools)..."
try {
npm install --silent 2>&1 | Out-Null
Invoke-NpmInstallSilent -WorkingDir $InstallDir
Write-Success "Node.js dependencies installed"
} catch {
Write-Warn "npm install failed (browser tools may not work)"
Write-Warn "Browser tools npm install could not be launched: $($_.Exception.Message)"
}
}
@@ -725,19 +853,13 @@ function Install-NodeDeps {
$tuiDir = "$InstallDir\ui-tui"
if (Test-Path "$tuiDir\package.json") {
Write-Info "Installing TUI dependencies..."
Push-Location $tuiDir
try {
npm install --silent 2>&1 | Out-Null
Invoke-NpmInstallSilent -WorkingDir $tuiDir
Write-Success "TUI dependencies installed"
} catch {
Write-Warn "TUI npm install failed (hermes --tui may not work)"
Write-Warn "TUI npm install could not be launched: $($_.Exception.Message)"
}
Pop-Location
}
Pop-Location
}
function Invoke-SetupWizard {

View File

@@ -0,0 +1,74 @@
"""Regression tests for Windows install.ps1 dependency branch handling.
These assertions lock in the critical control-flow paths needed for native
Windows CLI + TUI installs:
- Node.js install via winget, with managed ZIP fallback
- npm invocation that avoids execution-policy failures on npm.ps1
- Python dependency fallback chain for Windows CLI/TUI
- Managed Node PATH/HERMES_NODE persistence across terminal sessions
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
def test_node_install_keeps_winget_and_zip_fallback_paths() -> None:
text = INSTALL_PS1.read_text()
# Primary path: modern Windows machines with winget.
assert "if (Get-Command winget -ErrorAction SilentlyContinue)" in text
assert "winget install OpenJS.NodeJS.LTS" in text
# Fallback path: no winget / winget failure => managed ZIP install.
assert 'Write-Info "Downloading Node.js $NodeVersion binary..."' in text
assert 'Move-Item $extractedDir.FullName "$HermesHome\\node"' in text
assert '& "$HermesHome\\node\\node.exe" --version' in text
def test_system_packages_keep_winget_choco_scoop_fallback_chain() -> None:
text = INSTALL_PS1.read_text()
assert "$hasWinget = Get-Command winget -ErrorAction SilentlyContinue" in text
assert "$hasChoco = Get-Command choco -ErrorAction SilentlyContinue" in text
assert "$hasScoop = Get-Command scoop -ErrorAction SilentlyContinue" in text
assert "if ($hasWinget)" in text
assert "if ($hasChoco -and ($needRipgrep -or $needFfmpeg))" in text
assert "if ($hasScoop -and ($needRipgrep -or $needFfmpeg))" in text
def test_npm_resolution_avoids_powershell_policy_blocks() -> None:
text = INSTALL_PS1.read_text()
# Prefer npm.cmd and convert npm.ps1 -> npm.cmd when needed.
assert "function Resolve-NpmInvocation" in text
assert "Get-Command npm.cmd -ErrorAction SilentlyContinue" in text
assert '[System.IO.Path]::ChangeExtension($npm.Source, ".cmd")' in text
# Last-resort path should still work by launching npm-cli.js via node.
assert "node_modules\\npm\\bin\\npm-cli.js" in text
assert "Invoke-NpmInstallSilent -WorkingDir $InstallDir" in text
assert "Invoke-NpmInstallSilent -WorkingDir $tuiDir" in text
def test_python_dependency_install_has_windows_cli_tui_fallback() -> None:
text = INSTALL_PS1.read_text()
# Keep broad install attempt first.
assert '& $UvCmd pip install -e ".[all]"' in text
# Then fallback to Windows CLI/TUI essentials if optional extras fail.
assert '& $UvCmd pip install -e ".[pty,mcp,honcho,acp]"' in text
# Final safety fallback to base package.
assert '& $UvCmd pip install -e "."' in text
assert 'throw "Failed to install Hermes Python dependencies."' in text
def test_managed_node_is_persisted_for_future_tui_runs() -> None:
text = INSTALL_PS1.read_text()
assert "Add-UserPathEntry -CurrentPath $newPath -Entry $managedNodeDir" in text
assert '[Environment]::SetEnvironmentVariable("HERMES_NODE", $managedNodeExe, "User")' in text
assert '$env:Path = Add-UserPathEntry -CurrentPath $env:Path -Entry "$HermesHome\\node"' in text

View File

@@ -0,0 +1,182 @@
"""Unit tests for Windows UTF-8 process bootstrap."""
from __future__ import annotations
import os
from types import SimpleNamespace
import utf8_bootstrap as utf8_bootstrap
def _fake_sys(
*,
platform: str,
utf8_mode: int,
argv: list[str] | None = None,
executable: str = r"C:\Python\python.exe",
) -> SimpleNamespace:
return SimpleNamespace(
platform=platform,
flags=SimpleNamespace(utf8_mode=utf8_mode),
argv=argv or ["hermes"],
executable=executable,
)
def test_non_windows_noop(monkeypatch) -> None:
monkeypatch.setattr(
utf8_bootstrap,
"sys",
_fake_sys(platform="darwin", utf8_mode=0),
)
monkeypatch.delenv("PYTHONUTF8", raising=False)
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
called = {"exec": False}
def _fake_exec(*_args, **_kwargs):
called["exec"] = True
raise AssertionError("exec should not run on non-Windows")
monkeypatch.setattr(utf8_bootstrap.os, "execvpe", _fake_exec)
assert utf8_bootstrap.ensure_windows_utf8_mode() is False
assert called["exec"] is False
assert "PYTHONUTF8" not in os.environ
assert "PYTHONIOENCODING" not in os.environ
def test_windows_utf8_already_enabled_sets_env_without_reexec(monkeypatch) -> None:
monkeypatch.setattr(
utf8_bootstrap,
"sys",
_fake_sys(platform="win32", utf8_mode=1),
)
monkeypatch.delenv("PYTHONUTF8", raising=False)
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
called = {"exec": False}
def _fake_exec(*_args, **_kwargs):
called["exec"] = True
raise AssertionError("exec should not run when utf8_mode=1")
monkeypatch.setattr(utf8_bootstrap.os, "execvpe", _fake_exec)
assert utf8_bootstrap.ensure_windows_utf8_mode() is False
assert called["exec"] is False
assert os.environ["PYTHONUTF8"] == "1"
assert os.environ["PYTHONIOENCODING"] == "utf-8"
def test_windows_reexec_attempt_uses_utf8_flag(monkeypatch) -> None:
fake_sys = _fake_sys(platform="win32", utf8_mode=0, argv=["hermes", "--help"])
monkeypatch.setattr(utf8_bootstrap, "sys", fake_sys)
monkeypatch.delenv("PYTHONUTF8", raising=False)
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
monkeypatch.delenv("_HERMES_UTF8_REEXEC", raising=False)
captured: dict[str, object] = {}
def _fake_exec(executable, argv, env):
captured["executable"] = executable
captured["argv"] = argv
captured["env"] = env
raise OSError("blocked by test")
monkeypatch.setattr(utf8_bootstrap.os, "execvpe", _fake_exec)
assert (
utf8_bootstrap.ensure_windows_utf8_mode(entrypoint_markers=("hermes",))
is False
)
assert captured["executable"] == fake_sys.executable
assert captured["argv"] == [
fake_sys.executable,
"-X",
"utf8",
*fake_sys.argv,
]
env = captured["env"]
assert isinstance(env, dict)
assert env["PYTHONUTF8"] == "1"
assert env["PYTHONIOENCODING"] == "utf-8"
assert env["_HERMES_UTF8_REEXEC"] == "1"
def test_module_reexec_uses_dash_m_and_drops_argv0(monkeypatch) -> None:
fake_sys = _fake_sys(
platform="win32",
utf8_mode=0,
argv=[r"C:\Users\me\AppData\Local\Programs\Python\Scripts\hermes.exe", "chat", "--verbose"],
)
monkeypatch.setattr(utf8_bootstrap, "sys", fake_sys)
monkeypatch.delenv("_HERMES_UTF8_REEXEC", raising=False)
captured: dict[str, object] = {}
def _fake_exec(executable, argv, env):
captured["executable"] = executable
captured["argv"] = argv
captured["env"] = env
raise OSError("blocked by test")
monkeypatch.setattr(utf8_bootstrap.os, "execvpe", _fake_exec)
assert (
utf8_bootstrap.ensure_windows_utf8_mode(
module="hermes_cli.main",
entrypoint_markers=("hermes",),
)
is False
)
assert captured["executable"] == fake_sys.executable
assert captured["argv"] == [
fake_sys.executable,
"-X",
"utf8",
"-m",
"hermes_cli.main",
"chat",
"--verbose",
]
env = captured["env"]
assert isinstance(env, dict)
assert env["_HERMES_UTF8_REEXEC"] == "1"
def test_marker_mismatch_skips_reexec(monkeypatch) -> None:
fake_sys = _fake_sys(platform="win32", utf8_mode=0, argv=["pytest", "-k", "x"])
monkeypatch.setattr(utf8_bootstrap, "sys", fake_sys)
monkeypatch.delenv("_HERMES_UTF8_REEXEC", raising=False)
called = {"exec": False}
def _fake_exec(*_args, **_kwargs):
called["exec"] = True
raise AssertionError("exec should be skipped for non-matching marker")
monkeypatch.setattr(utf8_bootstrap.os, "execvpe", _fake_exec)
assert (
utf8_bootstrap.ensure_windows_utf8_mode(entrypoint_markers=("hermes",))
is False
)
assert called["exec"] is False
def test_reexec_guard_prevents_loops(monkeypatch) -> None:
fake_sys = _fake_sys(platform="win32", utf8_mode=0, argv=["hermes"])
monkeypatch.setattr(utf8_bootstrap, "sys", fake_sys)
monkeypatch.setenv("_HERMES_UTF8_REEXEC", "1")
called = {"exec": False}
def _fake_exec(*_args, **_kwargs):
called["exec"] = True
raise AssertionError("exec should be skipped when guard is set")
monkeypatch.setattr(utf8_bootstrap.os, "execvpe", _fake_exec)
assert utf8_bootstrap.ensure_windows_utf8_mode() is False
assert called["exec"] is False

View File

@@ -328,3 +328,36 @@ class TestSanePathIncludesHomebrew:
result = _make_run_env({})
# Should keep existing PATH unchanged
assert result["PATH"] == "/usr/bin:/bin"
class TestWindowsSanePath:
def test_make_run_env_windows_uses_windows_path_rules(self):
"""Windows mode should use ';' and avoid POSIX /usr/bin injections."""
from tools.environments.local import _make_run_env
with patch("tools.environments.local._IS_WINDOWS", True), patch.dict(
os.environ, {"PATH": r"C:\Users\Test\bin"}, clear=True
):
result = _make_run_env({})
assert "PATH" in result
assert ";" in result["PATH"]
assert "/usr/bin" not in result["PATH"]
parts = [p for p in result["PATH"].split(";") if p]
assert any("git" in p.lower() and "bin" in p.lower() for p in parts)
def test_make_run_env_windows_dedupes_case_insensitive_entries(self):
"""Repeated Windows path entries should not be appended twice."""
from tools.environments.local import _make_run_env
with patch("tools.environments.local._IS_WINDOWS", True), patch.dict(
os.environ, {"PATH": r"C:\Windows\System32;C:\TOOLS\BIN"}, clear=True
):
result = _make_run_env({})
normalized = [
p.replace("/", "\\").lower().rstrip("\\")
for p in result["PATH"].split(";")
if p
]
assert normalized.count(r"c:\windows\system32") == 1

View File

@@ -1,6 +1,7 @@
"""Local execution environment — spawn-per-call with session snapshot."""
import logging
import ntpath
import os
import platform
import re
@@ -217,6 +218,30 @@ _SANE_PATH = (
"/opt/homebrew/bin:/opt/homebrew/sbin:"
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
)
_SANE_PATH_WINDOWS = tuple(
p for p in (
os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32"),
os.environ.get("SystemRoot", r"C:\Windows"),
os.path.join(
os.environ.get("SystemRoot", r"C:\Windows"),
"System32",
"WindowsPowerShell",
"v1.0",
),
os.path.join(
os.environ.get("ProgramFiles", r"C:\Program Files"),
"Git",
"bin",
),
os.path.join(
os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
"Git",
"bin",
),
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Git", "bin"),
)
if p
)
def _make_run_env(env: dict) -> dict:
@@ -235,7 +260,24 @@ def _make_run_env(env: dict) -> dict:
elif k not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(k):
run_env[k] = v
existing_path = run_env.get("PATH", "")
if "/usr/bin" not in existing_path.split(":"):
if _IS_WINDOWS:
# Keep PATH Windows-native (`;` separator, case-insensitive dedupe)
# and avoid injecting POSIX defaults like /usr/bin.
parts = [p for p in existing_path.split(";") if p]
seen = {
ntpath.normcase(ntpath.normpath(p.rstrip("\\/")))
for p in parts
if p
}
for candidate in _SANE_PATH_WINDOWS:
norm = ntpath.normcase(ntpath.normpath(candidate.rstrip("\\/")))
if norm in seen:
continue
parts.append(candidate)
seen.add(norm)
if parts:
run_env["PATH"] = ";".join(parts)
elif "/usr/bin" not in existing_path.split(":"):
run_env["PATH"] = f"{existing_path}:{_SANE_PATH}" if existing_path else _SANE_PATH
# Per-profile HOME isolation: redirect system tool configs (git, ssh, gh,

81
utf8_bootstrap.py Normal file
View File

@@ -0,0 +1,81 @@
"""Windows UTF-8 bootstrap for Hermes entrypoints.
On older Windows builds, Python may start with a locale codec such as cp1252.
That makes text-mode ``open()`` without ``encoding=`` and stdio defaults prone
to Unicode decode/encode failures. Hermes touches many files in long-running
processes, so we force UTF-8 mode at process start for CLI entrypoints.
"""
from __future__ import annotations
import os
import sys
_UTF8_REEXEC_GUARD = "_HERMES_UTF8_REEXEC"
def ensure_windows_utf8_mode(
*,
reexec: bool = True,
module: str | None = None,
entrypoint_markers: tuple[str, ...] | None = None,
) -> bool:
"""Ensure UTF-8 defaults on Windows.
Behavior:
- Always sets ``PYTHONUTF8=1`` and ``PYTHONIOENCODING=utf-8`` on Windows.
- If Python is already in UTF-8 mode, returns immediately.
- Otherwise re-execs the current interpreter with ``-X utf8`` (once),
unless marker-gated or explicitly disabled via ``reexec=False``.
- When ``module=...`` is supplied, re-execs as ``python -m <module>`` and
forwards original user args (excluding argv0), which avoids Windows
console-script ``.exe`` wrappers being treated as Python scripts.
Returns ``True`` only when a re-exec is attempted and the exec call
unexpectedly returns (e.g. under a patched test double). In normal
operation ``os.execvpe`` never returns on success.
"""
if sys.platform != "win32":
return False
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
if getattr(sys.flags, "utf8_mode", 0) == 1:
return False
if not reexec:
return False
if os.environ.get(_UTF8_REEXEC_GUARD) == "1":
return False
if entrypoint_markers:
argv0 = ""
if getattr(sys, "argv", None):
argv0 = os.path.basename(str(sys.argv[0])).lower()
markers = tuple(marker.lower() for marker in entrypoint_markers if marker)
if markers and not any(marker in argv0 for marker in markers):
return False
executable = getattr(sys, "executable", None)
argv = list(getattr(sys, "argv", []))
if not executable:
return False
child_env = dict(os.environ)
child_env[_UTF8_REEXEC_GUARD] = "1"
child_argv = [executable, "-X", "utf8"]
if module:
child_argv.extend(["-m", module])
if len(argv) > 1:
child_argv.extend(argv[1:])
else:
child_argv.extend(argv)
try:
os.execvpe(executable, child_argv, child_env)
except OSError:
# Best-effort fallback: env vars remain set for child processes.
return False
# ``exec`` should not return on success.
return True