mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM
Three bugs from teknium1's successful install + diagnostic chat on Windows:
1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32
application".** Start-Process bypasses cmd.exe and PATHEXT to call
CreateProcessW directly, which refuses .cmd batch shims. Switched
Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe
install --silent *> $log``) which DOES honour PATHEXT. Extracted a
``_Run-NpmInstall`` helper so the browser + TUI paths share the same
logic. Captures $LASTEXITCODE correctly, still surfaces the real
stderr on failure with a log-file pointer for the full output.
2. **patch tool returns false-negative on Windows due to CRLF round-trip.**
Root cause was upstream of patch: ``subprocess.Popen(..., text=True,
stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows
through the stdin pipe. ``_pipe_stdin()`` was writing the patch's
new_content string through a text-mode pipe, bash then wrote those
CRLF bytes to disk, and patch's post-write verify compared the
on-disk CRLF bytes against the original LF-only string — fail.
Fixed in two places for defense in depth:
- ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with
explicit UTF-8 encoding, bypassing Python's newline translation on
every platform. No behaviour change on POSIX (bytes are identical)
but stops the CRLF injection on Windows.
- ``patch_replace``'s post-write verify normalizes CRLF→LF on both
sides before comparing, so even if some future backend still
translates newlines the patch tool won't report a bogus failure.
3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content
-Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed
in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees
the BOM (U+FEFF invisible char) and refuses to load the file, so
SOUL.md's persona instructions never get applied.
Fixed by writing the file via ``[System.IO.File]::WriteAllText``
with an explicit ``UTF8Encoding($false)`` — BOM-free on every
PowerShell version.
All POSIX behaviour verified unchanged: 198 tests pass across
test_file_operations, test_local_env_cwd_recovery, test_code_execution,
test_windows_native_support, test_windows_compat.
This commit is contained in:
parent
225b57f314
commit
2c7b479d16
3 changed files with 97 additions and 75 deletions
|
|
@ -903,13 +903,21 @@ function Copy-ConfigTemplates {
|
|||
Write-Info "~/.hermes/config.yaml already exists, keeping it"
|
||||
}
|
||||
|
||||
# Create SOUL.md if it doesn't exist (global persona file)
|
||||
# Create SOUL.md if it doesn't exist (global persona file).
|
||||
# IMPORTANT: write without a BOM. Windows PowerShell 5.1's
|
||||
# ``Set-Content -Encoding UTF8`` writes UTF-8 WITH a byte-order-mark
|
||||
# (the default PS5 behaviour), and Hermes's prompt-injection scanner
|
||||
# flags the BOM as an invisible unicode character and refuses to
|
||||
# load the file. PS7's ``-Encoding utf8NoBOM`` fixes that but we
|
||||
# don't control which PowerShell version the user has. Go direct
|
||||
# to .NET with an explicit UTF8Encoding($false) — BOM-free on every
|
||||
# PowerShell version.
|
||||
$soulPath = "$HermesHome\SOUL.md"
|
||||
if (-not (Test-Path $soulPath)) {
|
||||
@"
|
||||
$soulContent = @"
|
||||
# Hermes Agent Persona
|
||||
|
||||
<!--
|
||||
<!--
|
||||
This file defines the agent's personality and tone.
|
||||
The agent will embody whatever you write here.
|
||||
Edit this to customize how Hermes communicates with you.
|
||||
|
|
@ -922,7 +930,9 @@ Examples:
|
|||
This file is loaded fresh each message -- no restart needed.
|
||||
Delete the contents (or this file) to use the default personality.
|
||||
-->
|
||||
"@ | Set-Content -Path $soulPath -Encoding UTF8
|
||||
"@
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom)
|
||||
Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)"
|
||||
}
|
||||
|
||||
|
|
@ -964,83 +974,64 @@ function Install-NodeDeps {
|
|||
}
|
||||
$npmExe = $npmCmd.Source
|
||||
|
||||
Push-Location $InstallDir
|
||||
|
||||
if (Test-Path "package.json") {
|
||||
Write-Info "Installing Node.js dependencies (browser tools)..."
|
||||
# Use Start-Process so we can capture the real exit code. PowerShell's
|
||||
# try/catch doesn't trigger on npm's non-zero exit — only on an unhandled
|
||||
# .NET exception (e.g. npm.cmd missing). Capturing stderr to a file
|
||||
# lets us surface the actual failure reason instead of a generic
|
||||
# "npm install failed" that hides what went wrong.
|
||||
$browserLog = "$env:TEMP\hermes-npm-browser-$(Get-Random).log"
|
||||
# Helper: run "npm install" in a given directory and surface the real
|
||||
# error when it fails. Returns $true on success.
|
||||
#
|
||||
# Implementation note: ``Start-Process -FilePath npm.cmd`` fails with
|
||||
# ``%1 is not a valid Win32 application`` on some PowerShell versions
|
||||
# because Start-Process bypasses cmd.exe / PATHEXT and expects a real
|
||||
# PE file. The invocation-operator ``& $npmExe`` routes through the
|
||||
# PowerShell command pipeline which DOES honour .cmd batch shims, so
|
||||
# it works uniformly for npm.cmd, npx.cmd, and bare .exe files.
|
||||
function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) {
|
||||
Push-Location $installDir
|
||||
try {
|
||||
$proc = Start-Process -FilePath $npmExe `
|
||||
-ArgumentList "install", "--silent" `
|
||||
-NoNewWindow -Wait -PassThru `
|
||||
-RedirectStandardOutput "NUL" `
|
||||
-RedirectStandardError $browserLog
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
Write-Success "Node.js dependencies installed"
|
||||
Remove-Item -Force $browserLog -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Write-Warn "npm install failed (browser tools may not work) — exit code $($proc.ExitCode)"
|
||||
if (Test-Path $browserLog) {
|
||||
$errText = (Get-Content $browserLog -Raw -ErrorAction SilentlyContinue)
|
||||
if ($errText) {
|
||||
# Show first ~800 chars — enough to see the real cause.
|
||||
$snippet = if ($errText.Length -gt 800) { $errText.Substring(0, 800) + "..." } else { $errText }
|
||||
Write-Info " npm stderr:"
|
||||
foreach ($line in $snippet -split "`n") {
|
||||
Write-Host " $line" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Info " Full log: $browserLog"
|
||||
}
|
||||
}
|
||||
Write-Info "Run manually later: cd `"$InstallDir`"; npm install"
|
||||
# Redirect ALL output streams to the log file via 2>&1 and then
|
||||
# ``Tee-Object`` / ``Out-File``. Simpler approach: call npm
|
||||
# with output redirected and inspect $LASTEXITCODE afterwards.
|
||||
& $npmPath install --silent *> $logPath
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -eq 0) {
|
||||
Write-Success "$label dependencies installed"
|
||||
Remove-Item -Force $logPath -ErrorAction SilentlyContinue
|
||||
return $true
|
||||
}
|
||||
Write-Warn "$label npm install failed — exit code $code"
|
||||
if (Test-Path $logPath) {
|
||||
$errText = (Get-Content $logPath -Raw -ErrorAction SilentlyContinue)
|
||||
if ($errText) {
|
||||
$snippet = if ($errText.Length -gt 1200) { $errText.Substring(0, 1200) + "..." } else { $errText }
|
||||
Write-Info " npm output:"
|
||||
foreach ($line in $snippet -split "`n") {
|
||||
Write-Host " $line" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Info " Full log: $logPath"
|
||||
}
|
||||
}
|
||||
Write-Info "Run manually later: cd `"$installDir`"; npm install"
|
||||
return $false
|
||||
} catch {
|
||||
Write-Warn "npm install could not be launched: $_"
|
||||
Write-Warn "$label npm install could not be launched: $_"
|
||||
return $false
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# Install TUI dependencies
|
||||
# Browser tools
|
||||
if (Test-Path "$InstallDir\package.json") {
|
||||
Write-Info "Installing Node.js dependencies (browser tools)..."
|
||||
$browserLog = "$env:TEMP\hermes-npm-browser-$(Get-Random).log"
|
||||
[void](_Run-NpmInstall "Browser tools" $InstallDir $browserLog $npmExe)
|
||||
}
|
||||
|
||||
# TUI
|
||||
$tuiDir = "$InstallDir\ui-tui"
|
||||
if (Test-Path "$tuiDir\package.json") {
|
||||
Write-Info "Installing TUI dependencies..."
|
||||
Push-Location $tuiDir
|
||||
$tuiLog = "$env:TEMP\hermes-npm-tui-$(Get-Random).log"
|
||||
try {
|
||||
$proc = Start-Process -FilePath $npmExe `
|
||||
-ArgumentList "install", "--silent" `
|
||||
-NoNewWindow -Wait -PassThru `
|
||||
-RedirectStandardOutput "NUL" `
|
||||
-RedirectStandardError $tuiLog
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
Write-Success "TUI dependencies installed"
|
||||
Remove-Item -Force $tuiLog -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Write-Warn "TUI npm install failed (hermes --tui may not work) — exit code $($proc.ExitCode)"
|
||||
if (Test-Path $tuiLog) {
|
||||
$errText = (Get-Content $tuiLog -Raw -ErrorAction SilentlyContinue)
|
||||
if ($errText) {
|
||||
$snippet = if ($errText.Length -gt 800) { $errText.Substring(0, 800) + "..." } else { $errText }
|
||||
Write-Info " npm stderr:"
|
||||
foreach ($line in $snippet -split "`n") {
|
||||
Write-Host " $line" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Info " Full log: $tuiLog"
|
||||
}
|
||||
}
|
||||
Write-Info "Run manually later: cd `"$tuiDir`"; npm install"
|
||||
}
|
||||
} catch {
|
||||
Write-Warn "TUI npm install could not be launched: $_"
|
||||
}
|
||||
Pop-Location
|
||||
[void](_Run-NpmInstall "TUI" $tuiDir $tuiLog $npmExe)
|
||||
}
|
||||
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
function Invoke-SetupWizard {
|
||||
|
|
|
|||
|
|
@ -99,12 +99,33 @@ def get_sandbox_dir() -> Path:
|
|||
|
||||
|
||||
def _pipe_stdin(proc: subprocess.Popen, data: str) -> None:
|
||||
"""Write *data* to proc.stdin on a daemon thread to avoid pipe-buffer deadlocks."""
|
||||
"""Write *data* to proc.stdin on a daemon thread to avoid pipe-buffer deadlocks.
|
||||
|
||||
On Windows, text-mode stdin (``text=True`` / ``encoding="utf-8"``)
|
||||
translates ``\\n`` → ``\\r\\n`` as the data flows through the pipe —
|
||||
which corrupts every write_file / patch call because the bytes that
|
||||
land on disk include injected carriage returns. The file IS created,
|
||||
but every subsequent byte-count / content compare against the
|
||||
caller's ``\\n``-only string fails.
|
||||
|
||||
Workaround: write through ``proc.stdin.buffer`` (the underlying byte
|
||||
buffer), encoding to UTF-8 ourselves. That bypasses Python's
|
||||
newline translation entirely on every platform. No behaviour change
|
||||
on POSIX — the byte sequence is identical to what text-mode would
|
||||
produce there.
|
||||
"""
|
||||
|
||||
def _write():
|
||||
try:
|
||||
proc.stdin.write(data)
|
||||
proc.stdin.close()
|
||||
# proc.stdin is a TextIOWrapper when text=True was set on the
|
||||
# Popen. Its ``.buffer`` attribute is the raw BufferedWriter
|
||||
# that bypasses newline translation. When Popen was created
|
||||
# in byte mode, proc.stdin is already a BufferedWriter with
|
||||
# no ``.buffer`` attribute — fall back to .write() directly.
|
||||
raw = data.encode("utf-8") if isinstance(data, str) else data
|
||||
target = getattr(proc.stdin, "buffer", proc.stdin)
|
||||
target.write(raw)
|
||||
target.close()
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -966,11 +966,21 @@ class ShellFileOperations(FileOperations):
|
|||
verify_result = self._exec(verify_cmd)
|
||||
if verify_result.exit_code != 0:
|
||||
return PatchResult(error=f"Post-write verification failed: could not re-read {path}")
|
||||
if verify_result.stdout != new_content:
|
||||
# Normalize line endings before comparing. On Windows, Python's
|
||||
# default text-mode ``open()`` translates ``\n`` → ``\r\n`` on
|
||||
# write, so the file on disk legitimately holds CRLFs while our
|
||||
# ``new_content`` string has bare LFs. Without this normalization
|
||||
# every patch on Windows returns a bogus "wrote 39, read 42"
|
||||
# false-negative even though the edit landed correctly. POSIX
|
||||
# backends don't translate, so this is a no-op there.
|
||||
_verify_stdout_normalized = verify_result.stdout.replace("\r\n", "\n").replace("\r", "\n")
|
||||
_new_content_normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
if _verify_stdout_normalized != _new_content_normalized:
|
||||
return PatchResult(error=(
|
||||
f"Post-write verification failed for {path}: on-disk content "
|
||||
f"differs from intended write "
|
||||
f"(wrote {len(new_content)} chars, read back {len(verify_result.stdout)}). "
|
||||
f"(wrote {len(_new_content_normalized)} chars, read back "
|
||||
f"{len(_verify_stdout_normalized)} chars after normalizing line endings). "
|
||||
"The patch did not persist. Re-read the file and try again."
|
||||
))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue