diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7bacfe8dae..13af38953b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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 - -"@ | 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 { diff --git a/tools/environments/base.py b/tools/environments/base.py index 2420b06b1b..a4fbea7b2f 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -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 diff --git a/tools/file_operations.py b/tools/file_operations.py index 92a948eaaf..022943d9f0 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -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." ))