# NousResearch/hermes-agent · Windows installer encoding and cache reliability issues

## Open now

- Nothing open on GitHub.

These issues and fixes concern Windows PowerShell installer failures caused by non-UTF-8 or non-ASCII encoding, corrupted or stale cached install scripts, and truncated downloads. Most members share this theme, with one or two entries appearing loosely related or unclear.

## TL;DR

- 11 member(s): 0 open issue(s), 5 closed issue(s), 0 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #67182. Verify it addresses your case.
- **Recommended:** read PR #67182 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-67182,pr-67369`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.22`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #66994: \[Setup\]: Installation didn't finish error
- #67000: \[Bug\]: Installer log
- #67193: \[Bug\]: GUI installer fails on non-English Windows — install.ps1 parse error from encoding, plus stderr decoded as UTF-8 hides the real message
- #67194: \[Bug\]: Hermes App
- #68163: bootstrap-installer: corrupt cached install.ps1 is trusted forever — every install fails at __manifest__ with parser errors until cache is manually deleted

## Prior & competing PRs

- **#67182: fix(install): keep install.ps1 pure ASCII so Windows PowerShell 5.1 doesn't misparse it (#66994/#67000)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `scripts/install.ps1`, `tests/test_install_ps1_ascii_only.py`
  - fix hunks:
  - `scripts/install.ps1`

    ```diff
    @@ -443,7 +443,7 @@ function Get-PowerShellHostExe {
     }
     
     function Install-Uv {
    -    # Hermes owns its own uv at $HermesHome\bin\uv.exe.  Always install there —
    +    # Hermes owns its own uv at $HermesHome\bin\uv.exe.  Always install there --
         # no PATH probing, no conda guards, no multi-location resolution chains.
         # The runtime update path (hermes_cli/managed_uv.py) looks in the same
         # place, so install.ps1 and `hermes update` stay in sync.
    ```

    ```diff
    @@ -528,7 +528,7 @@ function Ensure-NodeExeOnPath {
     # prior process is not visible here.  Later stages (Test-Python,
     # Install-Venv, Install-Dependencies, Install-PlatformSdks) call this
     # at the top to populate $script:UvCmd from the managed location.
    -# Throws if uv is not findable — the caller's stage then surfaces a
    +# Throws if uv is not findable -- the caller's stage then surfaces a
     # clean error via the stage-driver's try/catch.
     function Resolve-UvCmd {
         # Already resolved (default invocation path: Install-Uv ran earlier
    ```

    ```diff
    @@ -544,7 +544,7 @@ function Resolve-UvCmd {
             # Stale; fall through to re-discover.
         }
     
    -    # Check the managed location first — this is where Install-Uv puts it.
    +    # Check the managed location first -- this is where Install-Uv puts it.
         $managedUv = Join-Path $HermesHome "bin\uv.exe"
         if (Test-Path $managedUv) {
             $script:UvCmd = $managedUv
    ```

    ```diff
    @@ -1516,7 +1516,7 @@ function Install-Repository {
                         if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" }
                         # Managed installs should follow origin/$Branch exactly. If
                         # the checkout has diverged (or has local-only commits),
    -                    # ff-only pull cannot succeed — mirror ``hermes update`` and
    +                    # ff-only pull cannot succeed -- mirror ``hermes update`` and
                         # reset to the fetched remote so bootstrap/install can recover.
                         git -c windows.appendAtomically=false pull --ff-only origin $Branch
                         if ($LASTEXITCODE -ne 0) {
    ```

    ```diff
    @@ -1573,11 +1573,11 @@ function Install-Repository {
                                     Write-Host ""
                                     Write-Host "Conflicted files:"
                                     foreach ($file in $conflictedFiles) {
    -                                    Write-Host "  • $file"
    +                                    Write-Host "  - $file"
                                     }
                                 }
                                 Write-Host ""
    -                            Write-Info "Your stashed changes are preserved — nothing is lost."
    +                            Write-Info "Your stashed changes are preserved -- nothing is lost."
                                 Write-Info "  Stash ref: $autostashRef"
                                 git -c windows.appendAtomically=false reset --hard HEAD 2>$null | Out-Null
                                 Write-Info "Working tree reset to clean state."
    ```

    ```diff
    @@ -1798,8 +1798,8 @@ function Install-Venv {
                 # /End stops a running task instance; /Change /DISABLE stops it
                 # from re-firing mid-install. (The Startup-folder .vbs fallback is
                 # NOT touched: it only fires at logon, so it cannot respawn a
    -            # gateway mid-install.) Re-enabled in the finally below — including
    -            # on failure — but only for tasks that were enabled to begin with.
    +            # gateway mid-install.) Re-enabled in the finally below -- including
    +            # on failure -- but only for tasks that were enabled to begin with.
                 # Best-effort: a missing task just errors quietly.
                 try {
                     schtasks /Query /FO CSV 2>$null | ConvertFrom-Csv | Where-Object { $_.TaskName -like '*Hermes_Gateway*' } | ForEach-Object {
    ```

    ```diff
    @@ -1893,7 +1893,7 @@ function Install-Venv {
         }
     
         # Clean up parked venvs from previous installs whose handles have since
    -    # been released. Best-effort — a still-held tree just stays for next time.
    +    # been released. Best-effort -- a still-held tree just stays for next time.
         Get-ChildItem -Directory -Filter "venv.stale.*" -ErrorAction SilentlyContinue | ForEach-Object {
             Remove-Item -Recurse -Force $_.FullName -ErrorAction SilentlyContinue
         }
    ```

    ```diff
    @@ -1926,10 +1926,10 @@ function Install-Venv {
         } finally {
             Pop-Location
             # Re-arm the gateway autostart tasks disabled during the venv teardown
    -        # — in a finally so a failed teardown/creation can never strand the
    +        # -- in a finally so a failed teardown/creation can never strand the
             # user's gateway autostart in the disabled state. Same function scope,
             # so the list survives even under the stage-per-process bootstrap.
    -        # Deliberately NOT started here — dependencies aren't installed yet;
    +        # Deliberately NOT started here -- dependencies aren't installed yet;
             # the task fires normally on next logon and `hermes update` / the
             # gateway resume path handles the immediate restart.
             if ($gatewayTasksDisabled -and $gatewayTasksDisabled.Count -gt 0) {
    ```

    ```diff
    @@ -2247,7 +2247,7 @@ function Set-PathVariable {
     function Write-BootstrapMarker {
         # Writes $InstallDir\.hermes-bootstrap-complete which tells the Hermes
         # desktop app (apps/desktop/electron/main.ts) "install.ps1 ran
    -    # successfully — DON'T trigger the legacy first-launch bootstrap
    +    # successfully -- DON'T trigger the legacy first-launch bootstrap
         # runner."
         #
         # Schema mirrors what main.ts's writeBootstrapMarker() / isBootstrap
    ```

    ```diff
    @@ -2268,7 +2268,7 @@ function Write-BootstrapMarker {
         # Resolve the pinned commit: explicit -Commit wins, otherwise read
         # the checkout's HEAD via git. If git can't run, leave commit empty
         # and the marker will fail desktop validation (pinnedCommit.length
    -    # >= 7) — better to be invalid than wrong.
    +    # >= 7) -- better to be invalid than wrong.
         $pinnedCommit = $Commit
         if (-not $pinnedCommit) {
             # PS 5.1 doesn't support the ?. null-conditional operator, so
    ```

    ```diff
    @@ -2283,7 +2283,7 @@ function Write-BootstrapMarker {
                         $pinnedCommit = $resolved.Trim()
                     }
                 } catch {
    -                # Ignore — pinnedCommit stays empty, marker stays invalid,
    +                # Ignore -- pinnedCommit stays empty, marker stays invalid,
                     # desktop falls through to its legacy bootstrap path.
                 } finally {
                     Pop-Location
    ```

    ```diff
    @@ -2302,7 +2302,7 @@ function Write-BootstrapMarker {
             pinnedCommit  = $pinnedCommit
             pinnedBranch  = $pinnedBranch
             completedAt   = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
    -        # desktopVersion field intentionally omitted — only the desktop
    +        # desktopVersion field intentionally omitted -- only the desktop
             # app knows its own version, and the marker validator doesn't
             # require it. The desktop fills it in if/when it writes its
             # own marker (e.g. after a future in-app upgrade).
    ```

    ```diff
    @@ -2311,7 +2311,7 @@ function Write-BootstrapMarker {
     
         # Write WITHOUT a UTF-8 BOM. PowerShell 5.1's `Set-Content -Encoding UTF8`
         # always emits a BOM, and Node's plain JSON.parse rejects the BOM as an
    -    # unexpected character — so a BOM'd marker would silently fail the
    +    # unexpected character -- so a BOM'd marker would silently fail the
         # desktop's readJson(), make isBootstrapComplete() return null, and the
         # desktop would re-run the legacy bootstrap runner anyway. Defeats the
         # whole point. Use the .NET API directly for BOM-less UTF-8.
    ```

    ```diff
    @@ -2411,7 +2411,7 @@ function Install-NodeDeps {
             # Cross-process driver mode (Hermes-Setup.exe runs each -Stage NAME
             # in a fresh powershell.exe) means $script:HasNode set by Stage-Node
             # in the previous process isn't visible here. Re-probe rather than
    -        # trust the stale global — Stage-Node already ran successfully or
    +        # trust the stale global -- Stage-Node already ran successfully or
             # the bootstrap would've aborted, so npm is reachable.
             if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
                 Write-Info "Skipping Node.js dependencies (Node not installed)"
    ```

    ```diff
    @@ -2688,7 +2688,7 @@ function Clear-ElectronBuildCache {
     # Last-resort Electron mirror after GitHub download fails (#47266).
     $script:DesktopElectronFallbackMirror = "https://npmmirror.com/mirrors/electron/"
     
    -# Electron package dir — workspace-local nest first, then root hoist.
    +# Electron package dir -- workspace-local nest first, then root hoist.
     function Get-ElectronDir {
         param([string]$InstallDir)
         $desktopLocal = Join-Path $InstallDir 'apps\desktop\node_modules\electron'
    ```

    ```diff
    @@ -2769,7 +2769,7 @@ function Install-Desktop {
         #
         # The Tauri bootstrap installer's launch_hermes_desktop command
         # resolves apps/desktop/release/win-unpacked/Hermes.exe directly,
    -    # so an "unpacked" build (electron-builder --dir) is enough — we
    +    # so an "unpacked" build (electron-builder --dir) is enough -- we
         # don't need to produce an NSIS/MSI artifact here.
     
         # Always re-resolve Node here. Stages run in separate PowerShell processes,
    ```

    ```diff
    @@ -2823,7 +2823,7 @@ function Install-Desktop {
             #
             # The streaming sink in bootstrap.rs's run_install_script
             # captures every stdout/stderr line as it's emitted, so we don't
    -        # need a side TEMP log file — the installer's bootstrap log
    +        # need a side TEMP log file -- the installer's bootstrap log
             # IS the artifact a support engineer reads.
             #
             # Prefer `npm ci`: it wipes node_modules and reinstalls from the
    ```

    ```diff
    @@ -2878,7 +2878,7 @@ function Install-Desktop {
         # NOT signing the output. Combined with signAndEditExecutable=false in
         # apps/desktop/package.json's build.win block, electron-builder never
         # invokes signtool and therefore never fetches/extracts winCodeSign
    -    # (whose macOS symlinks crash 7-Zip on non-admin Windows — a dead end we
    +    # (whose macOS symlinks crash 7-Zip on non-admin Windows -- a dead end we
         # are NOT trying to work around). The Hermes icon + product name are
         # stamped onto Hermes.exe by our own rcedit step (Set-DesktopExeIdentity)
         # AFTER this build, completely decoupled from electron-builder signing.
    ```

    ```diff
    @@ -2949,7 +2949,7 @@ function Install-Desktop {
             Pop-Location
             throw
         } finally {
    -        # Restore env to whatever the caller had — don't leak our
    +        # Restore env to whatever the caller had -- don't leak our
             # signing-off override into anything install.ps1 invokes later
             # (Stage-PlatformSdks, etc.).
             $env:CSC_IDENTITY_AUTO_DISCOVERY = $prevCSCAuto
    ```

    ```diff
    @@ -2980,7 +2980,7 @@ function Install-Desktop {
     
         # 3b. The Hermes icon + identity are stamped onto Hermes.exe by the
         #     electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.mjs)
    -    #     during `npm run pack` above — for every build, so the installer's
    +    #     during `npm run pack` above -- for every build, so the installer's
         #     --update rebuild stays branded too. No separate stamp step needed here.
         #     electron-builder's own rcedit step stays disabled (signAndEditExecutable
         #     =false) because enabling it drags in signtool -> winCodeSign -> the
    ```

    ```diff
    @@ -2990,7 +2990,7 @@ function Install-Desktop {
         #     directory. Chromium's GPU/renderer sandboxes CHECK-fail with
         #     0x80000003 when this ACE is missing alongside orphan AppContainer
         #     SIDs under %LOCALAPPDATA% (electron/electron#51761, hermes-agent#38216).
    -    #     Best-effort — never fail an otherwise-good install over ACL repair.
    +    #     Best-effort -- never fail an otherwise-good install over ACL repair.
         try {
             $appDir = Split-Path -Parent $desktopExe
             & icacls $appDir /grant "*S-1-15-2-2:(OI)(CI)(RX)" /T /C /Q | Out-Null
    ```

    ```diff
    @@ -3006,7 +3006,7 @@ function Install-Desktop {
         # 4. Create Start Menu + Desktop shortcuts pointing DIRECTLY at the packed
         #    Hermes.exe. We deliberately do NOT point them at `hermes desktop`: that
         #    command rebuilds (npm install + electron-builder) on every launch,
    -    #    which would cost minutes each time. The packed exe is the consumer —
    +    #    which would cost minutes each time. The packed exe is the consumer --
         #    launching it directly is instant, and updates flow through the
         #    installer's --update path (which rebuilds once, then relaunches).
         New-DesktopShortcuts -TargetExe $desktopExe
    ```

    ```diff
    @@ -3062,11 +3062,11 @@ function New-DesktopShortcuts {
             # cached bitmap. Critical on the --update path: the exe was re-stamped
             # with the Hermes icon, but without this the shortcut can keep drawing
             # the old Electron icon until the user manually refreshes / reboots.
    -        # Best-effort and silent — never fail the install over a cosmetic cache.
    +        # Best-effort and silent -- never fail the install over a cosmetic cache.
             try {
                 & ie4uinit.exe -show 2>$null
             } catch {
    -            # ie4uinit may be absent/renamed on some SKUs — ignore.
    +            # ie4uinit may be absent/renamed on some SKUs -- ignore.
             }
         } catch {
             Write-Warn "Skipping shortcut creation: $($_.Exception.Message)"
    ```
  - `tests/test_install_ps1_ascii_only.py`

    ```diff
    @@ -0,0 +1,55 @@
    +"""Regression: install.ps1 must stay pure ASCII.
    +
    +Issues #66994 / #67000 reported the Windows GUI installer (Hermes-Setup.exe)
    +crashing before it did anything, with a cascade of PowerShell parser errors at
    +lines 1619 / 1770 ("Missing argument in parameter list", "A 'using' statement
    +must appear before any other statements in a script").
    +
    +Root cause: ``scripts/install.ps1`` has no UTF-8 BOM, and a commit added two
    +non-ASCII characters *inside double-quoted string literals* (a bullet and an
    +em-dash). Windows PowerShell 5.1 -- which the bootstrap runs the cached script
    +under -- reads a BOM-less ``.ps1`` in the system ANSI code page (e.g. CP1252),
    +not UTF-8. The em-dash's UTF-8 tail byte (0x94) decodes to a "smart" close-quote
    +(U+201D), which the PowerShell tokenizer treats as a string delimiter. That
    +prematurely closes the string and desyncs the parser for the rest of the file,
    +surfacing as unrelated syntax errors far downstream.
    +
    +Non-ASCII bytes inside ``#`` comments are harmless (the tokenizer skips a
    +comment to end-of-line regardless of what it contains), which is why the file
    +carried em-dashes in comments for months without breaking -- only a non-ASCII
    +byte in *code* (a string literal) triggers the desync.
    +
    +This test is source-level because Linux CI cannot execute the PowerShell
    +installer. Keeping the whole file ASCII-only is the transport-independent
    +invariant: pure ASCII cannot be misdecoded under any code page, BOM or not, so
    +the bug class cannot recur -- in a comment or a string.
    +"""
    +
    +from __future__ import annotations
    +
    +from pathlib import Path
    +
    +
    +REPO_ROOT = Path(__file__).resolve().parent.parent
    +INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
    +
    +
    +def test_install_ps1_is_pure_ascii() -> None:
    +    raw = INSTALL_PS1.read_bytes()
    +
    +    offenders = []
    +    line_no = 1
    +    for byte in raw:
    +        if byte == 0x0A:
    +            line_no += 1
    +        elif byte >= 0x80:
    +            offenders.append(line_no)
    +
    +    assert not offenders, (
    +        "scripts/install.ps1 must be pure ASCII so Windows PowerShell 5.1 "
    +        "(which reads a BOM-less .ps1 in the system ANSI code page, not UTF-8) "
    +        "cannot misdecode a byte into a stray quote and desync the parser "
    +        "(issues #66994 / #67000). Non-ASCII bytes found on line(s): "
    +        f"{sorted(set(offenders))}. Use ASCII equivalents (em-dash -> '--', "
    +        "bullet -> '-')."
    +    )
    ```
- **#67369: fix(bootstrap): decode localized PS output + refresh mutable install cache (#67193, #67194)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/bootstrap-installer/src-tauri/src/install_script.rs`, `apps/bootstrap-installer/src-tauri/src/powershell.rs`, `apps/bootstrap-installer/src-tauri/src/update.rs`
  - fix hunks:
  - `apps/bootstrap-installer/src-tauri/src/install_script.rs`

    ```diff
    @@ -70,6 +70,29 @@ fn is_valid_commit(s: &str) -> bool {
         (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
     }
     
    +/// Resolver cache plan for a pin that already has a local path computed.
    +///
    +/// Immutable commit pins reuse cache forever. Mutable branch/tag pins always
    +/// refresh, and only fall back to a stale cache when the refresh fails.
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub(crate) enum CachePlan {
    +    /// On-disk hit for an immutable pin — skip the network.
    +    Reuse,
    +    /// Download (or re-download). `stale_ok` means a failed refresh may return
    +    /// the existing cache file (mutable pins with a prior download).
    +    Fetch { stale_ok: bool },
    +}
    +
    +pub(crate) fn cache_plan(immutable: bool, cached_exists: bool) -> CachePlan {
    +    if immutable && cached_exists {
    +        CachePlan::Reuse
    +    } else {
    +        CachePlan::Fetch {
    +            stale_ok: !immutable && cached_exists,
    +        }
    +    }
    +}
    +
     /// Resolves the install script to use for this run.
     ///
     /// `pin` is the commit-or-branch from either Hermes-Setup's build-time
    ```

    ```diff
    @@ -100,9 +123,13 @@ pub async fn resolve(
         // 2. (Not implemented) bundled fallback.
     
         // 3. Network. Pin must be a real commit or a branch ref.
    -    let commit_or_ref = match (&pin.commit, &pin.branch) {
    -        (Some(c), _) if is_valid_commit(c) => c.clone(),
    -        (_, Some(b)) if !b.trim().is_empty() => b.clone(),
    +    //
    +    // Commit SHAs are immutable — permanent cache reuse is safe.
    +    // Branch/tag pins are moving refs: always try to refresh so "Retry install"
    +    // cannot keep reusing a poisoned install-main.ps1 forever (#67193).
    +    let (commit_or_ref, immutable) = match (&pin.commit, &pin.branch) {
    +        (Some(c), _) if is_valid_commit(c) => (c.clone(), true),
    +        (_, Some(b)) if !b.trim().is_empty() => (b.clone(), false),
             (Some(other), _) => {
                 return Err(anyhow!(
                     "install script pin commit `{other}` is not a valid git SHA"
    ```

    ```diff
    @@ -116,36 +143,60 @@ pub async fn resolve(
         };
     
         let cached = cached_path(kind, &commit_or_ref);
    -    if cached.exists() {
    -        emit_log(&format!(
    -            "[bootstrap] using cached {} for {}",
    -            kind.filename(),
    -            truncate_ref(&commit_or_ref)
    -        ));
    -        return Ok(ResolvedScript {
    -            path: cached,
    -            source: ScriptSource::Cached,
    -            commit: pin.commit.clone(),
    -            branch: pin.branch.clone(),
    -        });
    -    }
    -
    -    emit_log(&format!(
    -        "[bootstrap] downloading {} for {} from GitHub",
    -        kind.filename(),
    -        truncate_ref(&commit_or_ref)
    -    ));
    -
    -    download(kind, &commit_or_ref, &cached).await?;
    -
    -    emit_log(&format!("[bootstrap] cached to {}", cached.display()));
    +    match cache_plan(immutable, cached.exists()) {
    +        CachePlan::Reuse => {
    +            emit_log(&format!(
    +                "[bootstrap] using cached {} for {}",
    +                kind.filename(),
    +                truncate_ref(&commit_or_ref)
    +            ));
    +            return Ok(ResolvedScript {
    +                path: cached,
    +                source: ScriptSource::Cached,
    +                commit: pin.commit.clone(),
    +                branch: pin.branch.clone(),
    +            });
    +        }
    +        CachePlan::Fetch { stale_ok } => {
    +            emit_log(&format!(
    +                "[bootstrap] downloading {} for {} {} from GitHub",
    +                kind.filename(),
    +                if immutable {
    +                    "commit"
    +                } else {
    +                    "mutable ref"
    +                },
    +                truncate_ref(&commit_or_ref)
    +            ));
     
    -    Ok(ResolvedScript {
    -        path: cached,
    -        source: ScriptSource::Downloaded,
    -        commit: pin.commit.clone(),
    -        branch: pin.branch.clone(),
    -    })
    +            match download(kind, &commit_or_ref, &cached).await {
    +                Ok(()) => {
    +                    emit_log(&format!("[bootstrap] cached to {}", cached.display()));
    +                    Ok(ResolvedScript {
    +                        path: cached,
    +                        source: ScriptSource::Downloaded,
    +                        commit: pin.commit.clone(),
    +                        branch: pin.branch.clone(),
    +                    })
    +                }
    +                Err(err) if stale_ok => {
    +                    emit_log(&format!(
    +                        "[bootstrap] WARNING: refresh failed for mutable ref {}; using stale cached {} at {}: {err:#}",
    +                        truncate_ref(&commit_or_ref),
    +                        kind.filename(),
    +                        cached.display()
    +                    ));
    +                    Ok(ResolvedScript {
    +                        path: cached,
    +                        source: ScriptSource::Cached,
    +                        commit: pin.commit.clone(),
    +                        branch: pin.branch.clone(),
    +                    })
    +                }
    +                Err(err) => Err(err),
    +            }
    +        }
    +    }
     }
     
     #[derive(Debug, Clone, Default)]
    ```

    ```diff
    @@ -185,6 +236,33 @@ fn truncate_ref(s: &str) -> &str {
         }
     }
     
    +/// UTF-8 BOM. Windows PowerShell 5.1 reads a BOM-less `.ps1` using the system
    +/// ANSI code page; a leading BOM is what tells it the file is UTF-8. The
    +/// `irm | iex` / `[scriptblock]::Create` path strips BOMs on purpose, but the
    +/// GUI bootstrap runs the *cached file* via `-File`, so we write the opposite
    +/// (#67193).
    +const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
    +
    +/// Prepare bytes for the on-disk bootstrap cache.
    +///
    +/// `.ps1` files get a UTF-8 BOM (unless one is already present). `.sh` files
    +/// are left unchanged — a BOM would break `#!/bin/bash`.
    +pub(crate) fn prepare_cached_script_bytes(kind: ScriptKind, bytes: &[u8]) -> Vec<u8> {
    +    match kind {
    +        ScriptKind::Ps1 => {
    +            if bytes.starts_with(UTF8_BOM) {
    +                bytes.to_vec()
    +            } else {
    +                let mut out = Vec::with_capacity(UTF8_BOM.len() + bytes.len());
    +                out.extend_from_slice(UTF8_BOM);
    +                out.extend_from_slice(bytes);
    +                out
    +            }
    +        }
    +        ScriptKind::Sh => bytes.to_vec(),
    +    }
    +}
    +
     /// Downloads to `dest_path` via reqwest with rustls. Atomically renames
     /// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache.
     async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> {
    ```

    ```diff
    @@ -228,6 +306,7 @@ async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Re
             .bytes()
             .await
             .with_context(|| format!("reading body of {url}"))?;
    +    let bytes = prepare_cached_script_bytes(kind, &bytes);
     
         let mut file = tokio::fs::File::create(&tmp_path)
             .await
    ```

    ```diff
    @@ -270,4 +349,58 @@ mod tests {
             assert_eq!(sanitize_ref("main"), "main");
             assert_eq!(sanitize_ref("release/1.2.3"), "release_1.2.3");
         }
    +
    +    #[test]
    +    fn prepare_cached_ps1_prefixes_utf8_bom() {
    +        let out = prepare_cached_script_bytes(ScriptKind::Ps1, b"Write-Host hi\n");
    +        assert!(out.starts_with(UTF8_BOM), "cached .ps1 must start with UTF-8 BOM");
    +        assert_eq!(&out[UTF8_BOM.len()..], b"Write-Host hi\n");
    +    }
    +
    +    #[test]
    +    fn prepare_cached_ps1_does_not_double_bom() {
    +        let mut already = UTF8_BOM.to_vec();
    +        already.extend_from_slice(b"x");
    +        let out = prepare_cached_script_bytes(ScriptKind::Ps1, &already);
    +        assert_eq!(out, already);
    +        assert_eq!(out.windows(3).filter(|w| *w == UTF8_BOM).count(), 1);
    +    }
    +
    +    #[test]
    +    fn prepare_cached_sh_stays_bomless() {
    +        let out = prepare_cached_script_bytes(ScriptKind::Sh, b"#!/bin/bash\n");
    +        assert!(!out.starts_with(UTF8_BOM));
    +        assert_eq!(out, b"#!/bin/bash\n");
    +    }
    +
    +    #[test]
    +    fn commit_pins_are_immutable_branch_pins_are_not() {
    +        // Mirrors the resolve() immutable decision: SHA pins may reuse cache
    +        // forever; branch pins must refresh so Retry cannot keep a bad script.
    +        assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0"));
    +        assert!(!is_valid_commit("main"));
    +        assert!(!is_valid_commit("release/1.2.3"));
    +    }
    +
    +    #[test]
    +    fn existing_branch_cache_plans_refresh_with_stale_fallback() {
    +        // Resolver-level: a prior install-main.ps1 must not short-circuit
    +        // Retry — mutable pins refresh, and only fall back if download fails.
    +        assert_eq!(
    +            cache_plan(/*immutable=*/ false, /*cached_exists=*/ true),
    +            CachePlan::Fetch { stale_ok: true }
    +        );
    +        assert_eq!(
    +            cache_plan(/*immutable=*/ true, /*cached_exists=*/ true),
    +            CachePlan::Reuse
    +        );
    +        assert_eq!(
    +            cache_plan(/*immutable=*/ false, /*cached_exists=*/ false),
    +            CachePlan::Fetch { stale_ok: false }
    +        );
    +        assert_eq!(
    +            cache_plan(/*immutable=*/ true, /*cached_exists=*/ false),
    +            CachePlan::Fetch { stale_ok: false }
    +        );
    +    }
     }
    ```
  - `apps/bootstrap-installer/src-tauri/src/powershell.rs`

    ```diff
    @@ -13,6 +13,103 @@ use tokio::io::{AsyncBufReadExt, BufReader};
     use tokio::process::{Child, Command};
     use tokio::sync::mpsc;
     
    +/// CP1252 mapping for bytes `0x80..=0x9F` (the range that differs from Latin-1).
    +/// Undefined slots keep the C1 control code points, matching Windows-1252
    +/// best-fit behavior used by `encoding_rs::WINDOWS_1252`.
    +const CP1252_80_9F: [char; 32] = [
    +    '\u{20AC}', // 0x80 €
    +    '\u{0081}', // 0x81
    +    '\u{201A}', // 0x82 ‚
    +    '\u{0192}', // 0x83 ƒ
    +    '\u{201E}', // 0x84 „
    +    '\u{2026}', // 0x85 …
    +    '\u{2020}', // 0x86 †
    +    '\u{2021}', // 0x87 ‡
    +    '\u{02C6}', // 0x88 ˆ
    +    '\u{2030}', // 0x89 ‰
    +    '\u{0160}', // 0x8A Š
    +    '\u{2039}', // 0x8B ‹
    +    '\u{0152}', // 0x8C Œ
    +    '\u{008D}', // 0x8D
    +    '\u{017D}', // 0x8E Ž
    +    '\u{008F}', // 0x8F
    +    '\u{0090}', // 0x90
    +    '\u{2018}', // 0x91 ‘
    +    '\u{2019}', // 0x92 ’
    +    '\u{201C}', // 0x93 “
    +    '\u{201D}', // 0x94 ”
    +    '\u{2022}', // 0x95 •
    +    '\u{2013}', // 0x96 –
    +    '\u{2014}', // 0x97 —
    +    '\u{02DC}', // 0x98 ˜
    +    '\u{2122}', // 0x99 ™
    +    '\u{0161}', // 0x9A š
    +    '\u{203A}', // 0x9B ›
    +    '\u{0153}', // 0x9C œ
    +    '\u{009D}', // 0x9D
    +    '\u{017E}', // 0x9E ž
    +    '\u{0178}', // 0x9F Ÿ
    +];
    +
    +fn decode_cp1252_byte(b: u8) -> char {
    +    match b {
    +        0x00..=0x7F => b as char,
    +        0x80..=0x9F => CP1252_80_9F[(b - 0x80) as usize],
    +        // 0xA0..=0xFF match Unicode Latin-1 / Windows-1252.
    +        _ => b as char,
    +    }
    +}
    +
    +/// Decode one stdout/stderr line from a child process.
    +///
    +/// Tokio's `BufReader::lines()` requires valid UTF-8 and aborts the line (with
    +/// `stream did not contain valid UTF-8`) at the first accented byte. Windows
    +/// PowerShell 5.1 emits localized ParserError text in the console ANSI code
    +/// page (often CP1252), so Portuguese/Spanish/etc. users only saw a truncated
    +/// `No` instead of `Não foi fornecido o terminador...` (#67193).
    +///
    +/// Prefer UTF-8 when the bytes are valid; otherwise decode as Windows-1252 so
    +/// both Western-European letters and CP1252-only punctuation (e.g. `0x91` →
    +/// U+2018) survive rather than disappearing into a read-error warning.
    +pub(crate) fn decode_console_bytes(bytes: &[u8]) -> String {
    +    match std::str::from_utf8(bytes) {
    +        Ok(s) => s.to_string(),
    +        Err(_) => bytes.iter().copied().map(decode_cp1252_byte).collect(),
    +    }
    +}
    +
    +/// Read one line (LF or CRLF) and decode it with [`decode_console_bytes`].
    +/// Returns `Ok(None)` on EOF with no bytes pending.
    +pub(crate) async fn read_decoded_line<R>(
    +    reader: &mut R,
    +    buf: &mut Vec<u8>,
    +) -> std::io::Result<Option<String>>
    +where
    +    R: AsyncBufReadExt + Unpin,
    +{
    +    // Cancel-safety: `buf` is NOT cleared on entry. When this future is
    +    // dropped mid-read inside `tokio::select!` (the other stream produced a
    +    // line first), `read_until` has already appended any consumed bytes to
    +    // `buf`; the next call resumes and appends the rest of the line. Clearing
    +    // on entry would silently drop those bytes. We clear only after a full
    +    // line has been decoded.
    +    let n = reader.read_until(b'\n', buf).await?;
    +    if n == 0 && buf.is_empty() {
    +        return Ok(None);
    +    }
    +    // n == 0 with a non-empty buf means EOF cut off an unterminated line
    +    // (possibly accumulated across cancelled reads) -- emit it.
    +    if buf.last() == Some(&b'\n') {
    +        buf.pop();
    +        if buf.last() == Some(&b'\r') {
    +            buf.pop();
    +        }
    +    }
    +    let line = decode_console_bytes(buf);
    +    buf.clear();
    +    Ok(Some(line))
    +}
    +
     /// Hooks the caller installs to receive output.
     pub struct StreamSink {
         pub on_stdout_line: Box<dyn Fn(&str) + Send + Sync>,
    ```

    ```diff
    @@ -77,8 +174,13 @@ pub async fn run_script(
         let stdout = child.stdout.take().expect("stdout was piped");
         let stderr = child.stderr.take().expect("stderr was piped");
     
    -    let mut stdout_reader = BufReader::new(stdout).lines();
    -    let mut stderr_reader = BufReader::new(stderr).lines();
    +    // Byte-oriented readers + [`decode_console_bytes`]: do NOT use
    +    // `BufReader::lines()`, which requires valid UTF-8 and hides localized
    +    // PowerShell errors on non-English Windows (#67193).
    +    let mut stdout_reader = BufReader::new(stdout);
    +    let mut stderr_reader = BufReader::new(stderr);
    +    let mut stdout_buf = Vec::new();
    +    let mut stderr_buf = Vec::new();
     
         let mut combined_stdout = String::new();
         let mut combined_stderr = String::new();
    ```

    ```diff
    @@ -87,7 +189,7 @@ pub async fn run_script(
         // Loop: poll stdout, stderr, cancel, and child exit concurrently.
         loop {
             tokio::select! {
    -            line = stdout_reader.next_line() => {
    +            line = read_decoded_line(&mut stdout_reader, &mut stdout_buf) => {
                     match line {
                         Ok(Some(l)) => {
                             (sink.on_stdout_line)(&l);
    ```

    ```diff
    @@ -104,7 +206,7 @@ pub async fn run_script(
                         }
                     }
                 }
    -            line = stderr_reader.next_line() => {
    +            line = read_decoded_line(&mut stderr_reader, &mut stderr_buf) => {
                     match line {
                         Ok(Some(l)) => {
                             (sink.on_stderr_line)(&l);
    ```

    ```diff
    @@ -130,12 +232,12 @@ pub async fn run_script(
         }
     
         // Drain remaining lines after the loop exited.
    -    while let Ok(Some(l)) = stdout_reader.next_line().await {
    +    while let Ok(Some(l)) = read_decoded_line(&mut stdout_reader, &mut stdout_buf).await {
             (sink.on_stdout_line)(&l);
             combined_stdout.push_str(&l);
             combined_stdout.push('\n');
         }
    -    while let Ok(Some(l)) = stderr_reader.next_line().await {
    +    while let Ok(Some(l)) = read_decoded_line(&mut stderr_reader, &mut stderr_buf).await {
             (sink.on_stderr_line)(&l);
             combined_stderr.push_str(&l);
             combined_stderr.push('\n');
    ```

    ```diff
    @@ -354,4 +456,98 @@ info line
                 "unexpected powershell path: {normalized}"
             );
         }
    +
    +    #[test]
    +    fn decode_console_bytes_keeps_valid_utf8() {
    +        assert_eq!(decode_console_bytes("café — ok".as_bytes()), "café — ok");
    +    }
    +
    +    #[test]
    +    fn decode_console_bytes_preserves_cp1252_portuguese_error() {
    +        // "Não foi fornecido o terminador..." as Windows PowerShell 5.1 emits
    +        // under CP1252 (0xE3 = ã). BufReader::lines() previously failed here
    +        // with "stream did not contain valid UTF-8" and the UI only showed "No".
    +        let bytes: &[u8] = b"N\xE3o foi fornecido o terminador";
    +        assert_eq!(decode_console_bytes(bytes), "Não foi fornecido o terminador");
    +    }
    +
    +    #[test]
    +    fn decode_console_bytes_maps_cp1252_only_punctuation() {
    +        // 0x91/0x92 are curly quotes in Windows-1252, but C1 controls under
    +        // Latin-1 (`b as char`). This locks the real CP1252 fallback.
    +        let bytes: &[u8] = b"say \x91hi\x92";
    +        assert_eq!(decode_console_bytes(bytes), "say \u{2018}hi\u{2019}");
    +        assert_ne!(
    +            decode_console_bytes(bytes),
    +            bytes.iter().map(|&b| b as char).collect::<String>(),
    +            "Latin-1 byte mapping must not be used for the 0x80..=0x9F range"
    +        );
    +    }
    +
    +    #[tokio::test]
    +    async fn read_decoded_line_survives_non_utf8_and_crlf() {
    +        let data: &[u8] = b"N\xE3o erro\r\nnext\n";
    +        let mut reader = BufReader::new(data);
    +        let mut buf = Vec::new();
    +        assert_eq!(
    +            read_decoded_line(&mut reader, &mut buf)
    +                .await
    +                .unwrap()
    +                .as_deref(),
    +            Some("Não erro")
    +        );
    +        assert_eq!(
    +            read_decoded_line(&mut reader, &mut buf)
    +                .await
    +                .unwrap()
    +                .as_deref(),
    +            Some("next")
    +        );
    +        assert!(read_decoded_line(&mut reader, &mut buf)
    +            .await
    +            .unwrap()
    +            .is_none());
    +    }
    +
    +    #[tokio::test]
    +    async fn read_decoded_line_preserves_partial_line_across_cancellation() {
    +        use std::time::Duration;
    +        use tokio::io::AsyncWriteExt;
    +
    +        let (mut tx, rx) = tokio::io::duplex(64);
    +        let mut reader = BufReader::new(rx);
    +        let mut buf = Vec::new();
    +
    +        tx.write_all(b"partial").await.unwrap();
    +        // Poll once, then cancel (drop) the future -- exactly what
    +        // tokio::select! does in run_script when the other stream produces
    +        // a line first. The consumed bytes must survive in `buf`.
    +        let _ = tokio::time::timeout(
    +            Duration::from_millis(0),
    +            read_decoded_line(&mut reader, &mut buf),
    +        )
    +        .await;
    +
    +        tx.write_all(b" line\n").await.unwrap();
    +        let line = read_decoded_line(&mut reader, &mut buf).await.unwrap();
    +        assert_eq!(line.as_deref(), Some("partial line"));
    +    }
    +
    +    #[tokio::test]
    +    async fn read_decoded_line_emits_unterminated_final_line_at_eof() {
    +        let data: &[u8] = b"no trailing newline";
    +        let mut reader = BufReader::new(data);
    +        let mut buf = Vec::new();
    +        assert_eq!(
    +            read_decoded_line(&mut reader, &mut buf)
    +                .await
    +                .unwrap()
    +                .as_deref(),
    +            Some("no trailing newline")
    +        );
    +        assert!(read_decoded_line(&mut reader, &mut buf)
    +            .await
    +            .unwrap()
    +            .is_none());
    +    }
     }
    ```
  - `apps/bootstrap-installer/src-tauri/src/update.rs`

    ```diff
    @@ -31,10 +31,11 @@ use std::time::{Duration, Instant};
     
     use anyhow::{anyhow, Result};
     use tauri::{AppHandle, Emitter};
    -use tokio::io::{AsyncBufReadExt, BufReader};
    +use tokio::io::BufReader;
     use tokio::process::Command;
     
     use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
    +use crate::powershell::read_decoded_line;
     
     /// `hermes update` exit code meaning "another hermes process is holding the
     /// venv shim open / dirty precondition" — see _cmd_update_impl in
    ```

    ```diff
    @@ -662,28 +663,31 @@ async fn run_streamed(
     
         let stdout = child.stdout.take().expect("stdout piped");
         let stderr = child.stderr.take().expect("stderr piped");
    -    let mut out = BufReader::new(stdout).lines();
    -    let mut err = BufReader::new(stderr).lines();
    +    // Same non-UTF-8-safe decode path as powershell::run_script (#67193).
    +    let mut out = BufReader::new(stdout);
    +    let mut err = BufReader::new(stderr);
    +    let mut out_buf = Vec::new();
    +    let mut err_buf = Vec::new();
     
         let stage_owned = stage.map(|s| s.to_string());
         loop {
             tokio::select! {
    -            line = out.next_line() => match line {
    +            line = read_decoded_line(&mut out, &mut out_buf) => match line {
                     Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l),
                     Ok(None) => break,
                     Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
                 },
    -            line = err.next_line() => match line {
    +            line = read_decoded_line(&mut err, &mut err_buf) => match line {
                     Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l),
                     Ok(None) => {}
                     Err(e) => { tracing::warn!("stderr read error: {e}"); }
                 },
             }
         }
    -    while let Ok(Some(l)) = out.next_line().await {
    +    while let Ok(Some(l)) = read_decoded_line(&mut out, &mut out_buf).await {
             emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l);
         }
    -    while let Ok(Some(l)) = err.next_line().await {
    +    while let Ok(Some(l)) = read_decoded_line(&mut err, &mut err_buf).await {
             emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l);
         }
     
    ```
- **#67021: fix(install): keep install.ps1 pure ASCII so Windows PowerShell 5.1 doesn't misparse it (#66994)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#67182`
- **#67269: fix: Windows GUI installer non-English parsing (fixes #67193)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `97249cfc8`, `#67369`, `#67214`
- **#67330: fix(bootstrap): handle non-UTF-8 PowerShell output with lossy decoding**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#67369`, `#67214`, `#67214`
- **#68180: fix(bootstrap): reject truncated install-script downloads via Content-Length (#68163)**. closed without merge
  - maintainer direction: category `wontfix_bydesign`; implication `wontfix`; refs `57b9de80ef`, `#68163`, `#67193`

## Fix status

- Merged to main in PR #67182 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-19`.
- Subsystem: `apps/bootstrap-installer`
