mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 04:38:43 +08:00
Compare commits
2 Commits
v2026.5.28
...
bb/windows
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b5bb9f0b5 | ||
|
|
31e3bdee99 |
@@ -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
7
cli.py
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/**/*"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
74
tests/test_install_ps1_windows_dependency_paths.py
Normal file
74
tests/test_install_ps1_windows_dependency_paths.py
Normal 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
|
||||
|
||||
182
tests/test_utf8_bootstrap.py
Normal file
182
tests/test_utf8_bootstrap.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
81
utf8_bootstrap.py
Normal 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
|
||||
Reference in New Issue
Block a user