diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index efc546d8eb51d..8694bb15ffe14 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -316,7 +316,8 @@ def uninstall_gateway_service(): # don't live in ~/.bashrc — they're in the Windows registry at # HKCU\Environment. # 2. Prepends to User-scope ``PATH`` (same registry location) entries -# like ``%LOCALAPPDATA%\hermes\git\cmd``, ``%LOCALAPPDATA%\hermes\git\bin``, +# like ``%LOCALAPPDATA%\hermes\bin`` (Hermes .cmd shims; #83797), +# ``%LOCALAPPDATA%\hermes\git\cmd``, ``%LOCALAPPDATA%\hermes\git\bin``, # ``%LOCALAPPDATA%\hermes\git\usr\bin``, ``%LOCALAPPDATA%\hermes\node``. # Again not in any rc file — only accessible via the registry or the # .NET [Environment] API. @@ -340,8 +341,9 @@ def _hermes_path_markers(hermes_home: Path) -> list[str]: """Path-entry substrings that identify Hermes-owned User-PATH entries.""" root = str(hermes_home).rstrip("\\/") # Match on prefix so sub-entries (git\cmd, git\bin, git\usr\bin, node, etc.) - # all get swept. Also match the bare hermes-agent install dir. - markers = [root + "\\hermes-agent", root + "\\git", root + "\\node", root + "\\venv"] + # all get swept. Also match the bare hermes-agent install dir and the + # Hermes-owned bin dir (hermes\bin) that ships .cmd shims (#83797). + markers = [root + "\\hermes-agent", root + "\\bin", root + "\\git", root + "\\node", root + "\\venv"] # Also match if HERMES_HOME was customised to somewhere else — find-and-nuke # any entry whose path component contains "hermes". We don't want to catch # unrelated entries like "chermes-foo" or "ephermeral", so we look for diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 5b810d04aba13..5f2920c3e7c03 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -2507,6 +2507,44 @@ def _ensure_fhs_path_guard() -> None: if wrote_any: print(" (reload your shell or run 'source ~/.bashrc' to pick it up)") +def _ensure_windows_acp_shim() -> None: + """Self-heal the ``hermes-acp`` command on Windows. + + install.ps1 ships ``hermes``/``hermes-acp`` as .cmd shims in + ``%LOCALAPPDATA%\\hermes\\bin`` and deliberately keeps the venv Scripts + dir off the user PATH (it contains python.exe/pip.exe — see #83797). + ``hermes update`` does not re-run install.ps1, so re-ensure the acp shim + here for installs that predate the shim layout (their venv Scripts entry + may still be on the user PATH, or may already have been migrated). + + The target path is hardcoded to the standard layout, so installs with a + custom install dir (``target.is_file()`` false) degrade silently: no + shim is written and the existing ``hermes-acp`` resolution keeps + working the way the custom install configured it. + """ + local_appdata = os.environ.get("LOCALAPPDATA", "") + if not local_appdata: + return + bin_dir = Path(local_appdata) / "hermes" / "bin" + target = ( + Path(local_appdata) / "hermes" / "hermes-agent" / "venv" / "Scripts" + / "hermes-acp.exe" + ) + if not target.is_file(): + return + shim = bin_dir / "hermes-acp.cmd" + # Text-mode write_text/read_text translate newlines on Windows, which + # would double the CRLF; go through bytes so the shim is verbatim. + encoding = "mbcs" if os.name == "nt" else "utf-8" + payload = ("@echo off\r\n@\"{}\" %*\r\n".format(target)).encode(encoding) + try: + bin_dir.mkdir(parents=True, exist_ok=True) + if not shim.exists() or shim.read_bytes() != payload: + shim.write_bytes(payload) + except (OSError, UnicodeError): + return + + def _ensure_acp_launcher() -> None: """Self-heal: install a ``hermes-acp`` launcher next to the ``hermes`` one. @@ -2522,12 +2560,16 @@ def _ensure_acp_launcher() -> None: (venv wrapper, FHS symlink, pipx/pip console script) without having to reconstruct interpreter/entrypoint paths. - No-op on Windows (install.ps1 puts ``venv\\Scripts`` on the user PATH, so - ``hermes-acp.exe`` already resolves) and wherever a ``hermes-acp`` is - already present next to the ``hermes`` command. Unwritable directories + On Windows, ``hermes-acp.exe`` resolves because install.ps1 puts a + forwarding .cmd shim in ``%LOCALAPPDATA%\\hermes\\bin`` (which is on the + user PATH) — the venv Scripts dir is intentionally not on that PATH since + it also hosts python.exe (#83797). ``_ensure_windows_acp_shim`` keeps the + shim in place across updates; everywhere else this function installs a + shell shim next to the ``hermes`` command. Unwritable directories (e.g. ``/usr/local/bin`` as non-root) are skipped silently. Idempotent. """ if _m().sys.platform == "win32": + _ensure_windows_acp_shim() return for bin_dir in (Path.home() / ".local" / "bin", Path("/usr/local/bin")): hermes_cmd = bin_dir / "hermes" diff --git a/scripts/ci/test_install_ps1_hermes_shim_path.ps1 b/scripts/ci/test_install_ps1_hermes_shim_path.ps1 new file mode 100644 index 0000000000000..4af548182ef8c --- /dev/null +++ b/scripts/ci/test_install_ps1_hermes_shim_path.ps1 @@ -0,0 +1,229 @@ +# Behavioral test for install.ps1's User-PATH shim migration +# (Update-UserPathForHermes): drops the legacy venv\Scripts entry that +# hijacked the user's `python` command (#83797) and ensures the Hermes bin +# (shim) dir is present. +# +# Run: pwsh -NoProfile -File scripts/ci/test_install_ps1_hermes_shim_path.ps1 +# +# Not wired into the default CI lane — the Linux runners have no PowerShell +# host. It runs on any machine with pwsh (including via nixpkgs#powershell), +# and on a Windows runner if one is ever added. +# +# Same harness style as test_install_ps1_path_migration.ps1: parses +# install.ps1, lifts the real Update-UserPathForHermes body out of the AST, +# and rewrites *only* the two registry calls into an in-memory store so the +# actual shipped logic — split, drop-legacy, ensure-shim-dir, change- +# detection — executes for real. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$installPs1 = Join-Path $PSScriptRoot '..' 'install.ps1' | Resolve-Path +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $installPs1, [ref]$null, [ref]$null) + +$fn = $ast.Find({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -eq 'Update-UserPathForHermes' +}, $true) + +if (-not $fn) { + throw "Update-UserPathForHermes not found in $installPs1" +} + +# Swap the two registry calls for the in-memory store. Both must match, or the +# function has changed shape and this harness is no longer exercising it. +$definition = $fn.Extent.Text +$reads = ([regex]'\[Environment\]::GetEnvironmentVariable\("Path", "User"\)').Matches($definition).Count +$writes = ([regex]'\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)').Matches($definition).Count +if ($reads -ne 1 -or $writes -ne 1) { + throw "expected exactly one User PATH read and one write in the function body; found $reads read(s), $writes write(s). Update this harness." +} + +$definition = $definition -replace ` + '\[Environment\]::GetEnvironmentVariable\("Path", "User"\)', '$script:FakeUserPath' +$definition = $definition -replace ` + '\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)', '$script:FakeUserPath = $1; $script:FakeWrites++' + +Invoke-Expression $definition + +$BIN = 'C:\Users\me\AppData\Local\hermes\bin' +$VENV_SCRIPTS = 'C:\Users\me\AppData\Local\hermes\hermes-agent\venv\Scripts' +$script:Failures = 0 + +function Invoke-Migration { + param([string]$Start, [string]$ShimDir = $BIN, [string]$Legacy = $VENV_SCRIPTS) + $script:FakeUserPath = $Start + $script:FakeWrites = 0 + Update-UserPathForHermes -ShimDir $ShimDir -LegacyVenvScripts $Legacy +} + +function Assert-Equal { + param($Expected, $Actual, [string]$Name) + if ($Expected -ceq $Actual) { + Write-Host " PASS $Name" + } else { + Write-Host " FAIL $Name" + Write-Host " expected: [$Expected]" + Write-Host " actual: [$Actual]" + $script:Failures++ + } +} + +Write-Host "install.ps1 Update-UserPathForHermes" + +# The regression this function exists for: installs made by the pre-2026-08 +# installer, which prepended the venv Scripts dir (host of python.exe/pip.exe) +# to the persisted User PATH, hijacking `python` in every new shell. +Invoke-Migration "$VENV_SCRIPTS;C:\Program Files\nodejs;C:\Users\me\bin" +Assert-Equal "$BIN;C:\Program Files\nodejs;C:\Users\me\bin" $script:FakeUserPath ` + 'upgrade from venv-Scripts installer: venv\Scripts dropped, shim dir prepended' +Assert-Equal 0 (@($script:FakeUserPath -split ';' | Where-Object { $_ -eq $VENV_SCRIPTS }).Count) ` + 'upgrade: venv\Scripts fully removed' +Assert-Equal 1 (@($script:FakeUserPath -split ';' | Where-Object { $_ -eq $BIN }).Count) ` + 'upgrade: shim dir present exactly once' +Assert-Equal 1 $script:FakeWrites 'upgrade: persists exactly once' + +# A shim dir already at the tail (e.g. from an earlier partial install) stays +# put; only the stale venv\Scripts entry goes away. +Invoke-Migration "C:\Program Files\nodejs;$VENV_SCRIPTS;$BIN" +Assert-Equal "C:\Program Files\nodejs;$BIN" $script:FakeUserPath ` + 'existing shim dir keeps its position, venv\Scripts removed' +Assert-Equal 1 $script:FakeWrites 'existing shim dir: persists exactly once' + +Invoke-Migration "$BIN;C:\Program Files\nodejs" +Assert-Equal "$BIN;C:\Program Files\nodejs" $script:FakeUserPath 'already correct: unchanged' +Assert-Equal 0 $script:FakeWrites 'already correct: no registry write' + +Invoke-Migration "C:\Program Files\nodejs" +Assert-Equal "$BIN;C:\Program Files\nodejs" $script:FakeUserPath 'fresh install: shim dir prepended' + +# Empty segments are legal in a real User PATH (a trailing ';' is common) and +# the installer's other PATH code preserves them. Migration must not quietly +# rewrite parts of PATH it was not asked to touch. +Invoke-Migration "C:\Program Files\nodejs;;C:\Users\me\bin;" +Assert-Equal "$BIN;C:\Program Files\nodejs;;C:\Users\me\bin;" $script:FakeUserPath ` + 'empty segments are preserved' + +# Windows paths are case-insensitive. +Invoke-Migration "c:\users\me\appdata\local\hermes\hermes-agent\venv\scripts;C:\Program Files\nodejs" +Assert-Equal "$BIN;C:\Program Files\nodejs" $script:FakeUserPath ` + 'legacy entry in different case is removed, not duplicated' + +# A trailing backslash on the registry entry must not defeat the removal. +Invoke-Migration "$VENV_SCRIPTS\;C:\Program Files\nodejs" +Assert-Equal "$BIN;C:\Program Files\nodejs" $script:FakeUserPath ` + 'trailing backslash variant is removed' + +# Duplicate legacy entries all collapse. +Invoke-Migration "$VENV_SCRIPTS;C:\Program Files\nodejs;$VENV_SCRIPTS" +Assert-Equal "$BIN;C:\Program Files\nodejs" $script:FakeUserPath 'duplicates collapse' + +# A trailing backslash on the shim dir must not defeat membership detection: +# the function normalizes it, so no duplicate entry is prepended. +Invoke-Migration "$BIN;C:\Program Files\nodejs" "$BIN\" +Assert-Equal "$BIN;C:\Program Files\nodejs" $script:FakeUserPath ` + 'trailing backslash on shim dir: no duplicate entry' +Assert-Equal 0 $script:FakeWrites 'trailing backslash on shim dir: no write' + +Invoke-Migration "" +Assert-Equal $BIN $script:FakeUserPath 'empty User PATH' + +# Empty LegacyVenvScripts (no-venv layout never calls this, but the guard +# must not filter unrelated entries when it is empty). +Invoke-Migration "C:\Program Files\nodejs;;" -Legacy "" +Assert-Equal "$BIN;C:\Program Files\nodejs;;" $script:FakeUserPath ` + 'empty legacy arg: no filtering, empty segments kept' + +Invoke-Migration "C:\Program Files\nodejs" "" "" +Assert-Equal "C:\Program Files\nodejs" $script:FakeUserPath 'empty ShimDir is a no-op' +Assert-Equal 0 $script:FakeWrites 'empty ShimDir does not write' + +# --- New-HermesShims (file-system behavior) -------------------------------- +# +# Unlike Update-UserPathForHermes this function has no registry calls, so it +# is tested against real temp dirs. + +$fn2 = $ast.Find({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -eq 'New-HermesShims' +}, $true) + +if (-not $fn2) { + throw "New-HermesShims not found in $installPs1" +} +Invoke-Expression $fn2.Extent.Text + +$shimRoot = Join-Path $env:TEMP ("hermes-shim-test-" + [guid]::NewGuid().ToString('N')) +$fakeVenv = Join-Path $shimRoot 'venv\Scripts' +$fakeBin = Join-Path $shimRoot 'bin' +New-Item -ItemType Directory -Path $fakeVenv -Force | Out-Null + +# Seed the venv with the two console scripts the installer ships. +Set-Content -LiteralPath (Join-Path $fakeVenv 'hermes.exe') -Value 'dummy' -Encoding ASCII +Set-Content -LiteralPath (Join-Path $fakeVenv 'hermes-acp.exe') -Value 'dummy' -Encoding ASCII + +function Assert-Shim { + param([string]$Name, [string]$Expected, [string]$TestName) + $shim = Join-Path $fakeBin "$Name.cmd" + $actual = Get-Content -LiteralPath $shim -Raw + if ($actual -ceq $Expected) { + Write-Host " PASS $TestName" + } else { + Write-Host " FAIL $TestName" + Write-Host " expected: [$Expected]" + Write-Host " actual: [$actual]" + $script:Failures++ + } +} + +$expectedHermes = "@echo off`r`n@`"$(Join-Path $fakeVenv 'hermes.exe')`" %*`r`n" +$expectedAcp = "@echo off`r`n@`"$(Join-Path $fakeVenv 'hermes-acp.exe')`" %*`r`n" + +New-HermesShims -ShimDir $fakeBin -VenvScripts $fakeVenv +Assert-Shim 'hermes' $expectedHermes 'shim created with quoted absolute target' +Assert-Shim 'hermes-acp' $expectedAcp 'acp shim created with quoted absolute target' + +# Idempotent: unchanged content must not rewrite (mtime stays put). +$before = (Get-Item (Join-Path $fakeBin 'hermes.cmd')).LastWriteTimeUtc +Start-Sleep -Milliseconds 50 +New-HermesShims -ShimDir $fakeBin -VenvScripts $fakeVenv +$after = (Get-Item (Join-Path $fakeBin 'hermes.cmd')).LastWriteTimeUtc +if ($before -eq $after) { + Write-Host ' PASS idempotent: no rewrite when content unchanged' +} else { + Write-Host ' FAIL idempotent: shim rewritten' + $script:Failures++ +} + +# A missing console script is skipped, not stubbed. +$sparseVenv = Join-Path $shimRoot 'sparse\Scripts' +$sparseBin = Join-Path $shimRoot 'sparse-bin' +New-Item -ItemType Directory -Path $sparseVenv -Force | Out-Null +Set-Content -LiteralPath (Join-Path $sparseVenv 'hermes.exe') -Value 'dummy' -Encoding ASCII +New-HermesShims -ShimDir $sparseBin -VenvScripts $sparseVenv +if ((Test-Path (Join-Path $sparseBin 'hermes.cmd')) -and + -not (Test-Path (Join-Path $sparseBin 'hermes-acp.cmd'))) { + Write-Host ' PASS missing console script is skipped, not stubbed' +} else { + Write-Host ' FAIL missing console script handling' + $script:Failures++ +} + +# Empty args are a no-op. +New-HermesShims -ShimDir '' -VenvScripts $fakeVenv +New-HermesShims -ShimDir $fakeBin -VenvScripts '' +Write-Host ' PASS empty args are a no-op' + +Remove-Item -Recurse -Force $shimRoot -ErrorAction SilentlyContinue + +if ($script:Failures -gt 0) { + Write-Host "" + Write-Host "$script:Failures assertion(s) failed" + exit 1 +} + +Write-Host "" +Write-Host "all assertions passed" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a0fa6591b5077..1c95061bb21a6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2709,30 +2709,118 @@ print(','.join(scripts)) Write-Success "All dependencies installed" } +# Keep the Hermes command surface global without putting python.exe on the +# user PATH. +# +# Background: installers before 2026-08 made `hermes` globally available by +# prepending the venv Scripts dir (`\venv\Scripts`) to the persisted +# User PATH. That dir also contains python.exe/pip.exe, so every new shell +# resolved `python` to the Hermes runtime instead of the user's own +# interpreter (#83797). The fix ships `hermes`/`hermes-acp` as .cmd shims in +# the Hermes-owned bin dir (`$HermesHome\bin`, which holds only +# Hermes-specific commands like uv) and migrates existing installs by +# dropping the stale venv\Scripts entry from the User PATH. +function New-HermesShims { + param( + [string]$ShimDir, + [string]$VenvScripts + ) + + if (-not $ShimDir -or -not $VenvScripts) { return } + New-Item -ItemType Directory -Path $ShimDir -Force | Out-Null + + foreach ($name in @('hermes', 'hermes-acp')) { + $target = Join-Path $VenvScripts "$name.exe" + if (-not (Test-Path -LiteralPath $target)) { continue } + $shim = Join-Path $ShimDir "$name.cmd" + $content = "@echo off`r`n@`"$target`" %*`r`n" + try { + if (-not (Test-Path -LiteralPath $shim) -or + (Get-Content -LiteralPath $shim -Raw) -ne $content) { + # -NoNewline: Set-Content would otherwise append a trailing + # newline, making the shim bytes diverge from $content and + # defeating the idempotence check on the next run. + Set-Content -LiteralPath $shim -Value $content -Encoding ASCII -NoNewline + } + } catch { + Write-Warn "Could not write shim $shim : $_" + } + } +} + +# Ensure the Hermes shim dir is on the persisted User PATH and drop the +# legacy venv\Scripts entry. Pure PATH logic (registry calls swapped for an +# in-memory store by the CI behavior test). +# +# Unrelated entries keep their relative order, including empty segments (a +# trailing ';' is legal and common in a real User PATH; Install-Git's +# splitting preserves them too, so this must not quietly rewrite them). +# PowerShell's -ne is case-insensitive for strings, which is the right +# comparison on Windows. Persists only when the resulting string differs, +# so an already-correct PATH costs one registry read and no write. +function Update-UserPathForHermes { + param( + [string]$ShimDir, + [string]$LegacyVenvScripts + ) + + if (-not $ShimDir) { return } + + # Registry-sourced paths often carry a trailing backslash; normalize so + # the membership check below cannot miss an existing entry and prepend a + # duplicate (mirrors the TrimEnd on $LegacyVenvScripts). + $ShimDir = $ShimDir.TrimEnd('\') + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $items = if ($userPath) { @($userPath -split ";") } else { @() } + + $kept = @($items) + if ($LegacyVenvScripts) { + $legacy = $LegacyVenvScripts.TrimEnd('\') + $kept = @($items | Where-Object { $_.TrimEnd('\') -ne $legacy }) + } + + $updated = @($kept) + if ($kept -notcontains $ShimDir) { + $updated = @($ShimDir) + $kept + } + + $joined = $updated -join ";" + if ($joined -ne $userPath) { + [Environment]::SetEnvironmentVariable("Path", $joined, "User") + } +} + function Set-PathVariable { Write-Info "Setting up hermes command..." - + if ($NoVenv) { + # Legacy no-venv layout: the install dir itself holds the commands. $hermesBin = "$InstallDir" + $currentPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ($currentPath -notlike "*$hermesBin*") { + [Environment]::SetEnvironmentVariable( + "Path", + "$hermesBin;$currentPath", + "User" + ) + Write-Success "Added to user PATH: $hermesBin" + } else { + Write-Info "PATH already configured" + } } else { - $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 - $currentPath = [Environment]::GetEnvironmentVariable("Path", "User") - - if ($currentPath -notlike "*$hermesBin*") { - [Environment]::SetEnvironmentVariable( - "Path", - "$hermesBin;$currentPath", - "User" - ) - Write-Success "Added to user PATH: $hermesBin" - } else { - Write-Info "PATH already configured" + # `hermes` / `hermes-acp` become globally available via .cmd shims + # in the Hermes-owned bin dir. The venv Scripts dir (which also + # contains python.exe/pip.exe) is deliberately NOT put on the user + # PATH so it cannot hijack the user's own `python` command + # (#83797). On Windows the hermes.exe in venv\Scripts\ has the + # venv Python baked in, and the shims delegate to it by absolute + # path, so they survive `hermes update` venv rebuilds. + $hermesBin = "$HermesHome\bin" + New-HermesShims -ShimDir $hermesBin -VenvScripts "$InstallDir\venv\Scripts" + Update-UserPathForHermes -ShimDir $hermesBin -LegacyVenvScripts "$InstallDir\venv\Scripts" } - + # 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 # of the Unix default ~/.hermes @@ -2742,10 +2830,10 @@ function Set-PathVariable { Write-Success "Set HERMES_HOME=$HermesHome" } $env:HERMES_HOME = $HermesHome - + # Update current session $env:Path = "$hermesBin;$env:Path" - + Write-Success "hermes command ready" } diff --git a/tests/hermes_cli/test_ensure_acp_launcher.py b/tests/hermes_cli/test_ensure_acp_launcher.py index 587e726fad24e..67b787ecc6c39 100644 --- a/tests/hermes_cli/test_ensure_acp_launcher.py +++ b/tests/hermes_cli/test_ensure_acp_launcher.py @@ -8,6 +8,7 @@ import os import stat +import sys from pathlib import Path from unittest.mock import patch @@ -25,12 +26,6 @@ def fake_home(tmp_path, monkeypatch): return bin_dir - - - - - - def test_does_not_follow_symlink_into_venv(fake_home, tmp_path): """#21454 failure mode: never write through a symlinked hermes-acp.""" (fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8") @@ -46,10 +41,6 @@ def test_does_not_follow_symlink_into_venv(fake_home, tmp_path): assert (fake_home / "hermes-acp").is_symlink() - - - - def test_unwritable_bin_dir_is_skipped(fake_home): (fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8") if os.geteuid() == 0: @@ -60,3 +51,74 @@ def test_unwritable_bin_dir_is_skipped(fake_home): assert not (fake_home / "hermes-acp").exists() finally: fake_home.chmod(0o755) + + +# --- Windows shim path (#83797) ------------------------------------------- +# +# install.ps1 keeps the venv Scripts dir OFF the user PATH (it hosts +# python.exe/pip.exe) and ships `hermes`/`hermes-acp` as .cmd shims in +# %LOCALAPPDATA%\hermes\bin instead. `hermes update` must keep that shim in +# place, so _ensure_acp_launcher's Windows branch re-creates it. + + +def _cmd_encoding(): + # mbcs (the Windows ANSI code page) is what cmd.exe expects for .cmd + # files; Linux test runners have no mbcs, so fall back to utf-8. + return "mbcs" if os.name == "nt" else "utf-8" + + +def _win_env(tmp_path, monkeypatch, with_target=True): + local = tmp_path / "local" + scripts = local / "hermes" / "hermes-agent" / "venv" / "Scripts" + if with_target: + scripts.mkdir(parents=True) + (scripts / "hermes-acp.exe").write_bytes(b"dummy") + monkeypatch.setenv("LOCALAPPDATA", str(local)) + monkeypatch.setattr(sys, "platform", "win32") + return local, scripts + + +def _shim_bytes(scripts): + return ( + "@echo off\r\n@\"{}\" %*\r\n".format(scripts / "hermes-acp.exe") + ).encode(_cmd_encoding()) + + +def test_windows_acp_shim_created(tmp_path, monkeypatch): + local, scripts = _win_env(tmp_path, monkeypatch) + + _ensure_acp_launcher() + + shim = local / "hermes" / "bin" / "hermes-acp.cmd" + assert shim.read_bytes() == _shim_bytes(scripts) + + +def test_windows_acp_shim_idempotent(tmp_path, monkeypatch): + local, _ = _win_env(tmp_path, monkeypatch) + + _ensure_acp_launcher() + shim = local / "hermes" / "bin" / "hermes-acp.cmd" + first_mtime = shim.stat().st_mtime_ns + + _ensure_acp_launcher() + + assert shim.stat().st_mtime_ns == first_mtime + + +def test_windows_acp_shim_missing_target_is_noop(tmp_path, monkeypatch): + local, _ = _win_env(tmp_path, monkeypatch, with_target=False) + + _ensure_acp_launcher() + + assert not (local / "hermes" / "bin").exists() + + +def test_windows_acp_shim_rewrites_stale_content(tmp_path, monkeypatch): + local, scripts = _win_env(tmp_path, monkeypatch) + stale = local / "hermes" / "bin" / "hermes-acp.cmd" + stale.parent.mkdir(parents=True) + stale.write_bytes(b"@echo off\r\n@old-path.exe %*\r\n") + + _ensure_acp_launcher() + + assert stale.read_bytes() == _shim_bytes(scripts)