Windows installer encoding and cache reliability issues

NousResearch/hermes-agent · cluster 66994 · apps/bootstrap-installer

Data as of

Nothing open on GitHub. Fix merged in #67182. Check it covers your case.

The short answer

0open issues
0open PRs
11items grouped
Known?
Yes. 11 related items grouped here, 5 issues already closed.
Fix event
A fix was merged to main in PR #67182. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #67182, #67369. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #67182 fix(install): keep install.ps1 pure ASCII so Windows PowerShell 5.1 doesn't misparse it (#66994/#67000)

    merged to main

    files: scripts/install.ps1, tests/test_install_ps1_ascii_only.py

    Show the diff
    • scripts/install.ps1
      @@ -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.
      @@ -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
      @@ -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
      @@ -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) {
      @@ -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."
      @@ -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 {
      @@ -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
           }
      @@ -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) {
      @@ -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
      @@ -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
      @@ -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
      @@ -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).
      @@ -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.
      @@ -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)"
      @@ -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'
      @@ -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,
      @@ -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
      @@ -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.
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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

    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

    Show the diff
    • apps/bootstrap-installer/src-tauri/src/install_script.rs
      @@ -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
      @@ -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"
      @@ -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)]
      @@ -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<()> {
      @@ -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
      @@ -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
      @@ -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>,
      @@ -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();
      @@ -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);
      @@ -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);
      @@ -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');
      @@ -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
      @@ -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
      @@ -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);
           }
       

4 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1 · superseded by another PR 1 · won't fix / by design 1

  • #67021 fix(install): keep install.ps1 pure ASCII so Windows PowerShell 5.1 doesn't misparse it (#66994) · merged via another change → #67182
  • #67269 fix: Windows GUI installer non-English parsing (fixes #67193) · done elsewhere → 97249cf, #67369, #67214
  • #67330 fix(bootstrap): handle non-UTF-8 PowerShell output with lossy decoding · superseded by another PR → #67369, #67214
  • #68180 fix(bootstrap): reject truncated install-script downloads via Content-Length (#68163) · won't fix / by design → 57b9de8, #68163, #67193
Closed issues · 5
  • Closed#66994[Setup]: Installation didn't finish error
  • Closed#67000[Bug]: Installer log
  • Closed#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
  • Closed#67194[Bug]: Hermes App
  • Closed#68163bootstrap-installer: corrupt cached install.ps1 is trusted forever — every install fails at __manifest__ with parser errors until cache is manually deleted
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/bootstrap-installer
Merged PRs
#67182, #67369
Closed issues
5
Attempts
4 PRs closed without merging
Fix link checked
19 Jul 2026

For your agent

The same facts, typed.