{"repo":"NousResearch/hermes-agent","public_id":"66994","display_label":"Windows installer encoding and cache reliability issues","summary":"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.","subsystem":"apps/bootstrap-installer","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":67182},"resolution":{"state":"partially_resolved","by":"pr-67182,pr-67369"},"freshness":{"state":"verified","last_verified_at":"2026-07-19T08:22:00+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22254545454545452},"fix_edges":[{"issue_number":66994,"pr_number":67182,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":67000,"pr_number":67182,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":67193,"pr_number":67369,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":67194,"pr_number":67369,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":66994,"title":"[Setup]: Installation didn't finish error","url":null,"state":"closed"},{"kind":"issue","number":67000,"title":"[Bug]: Installer log","url":null,"state":"closed"},{"kind":"issue","number":67193,"title":"[Bug]: GUI installer fails on non-English Windows — install.ps1 parse error from encoding, plus stderr decoded as UTF-8 hides the real message","url":null,"state":"closed"},{"kind":"issue","number":67194,"title":"[Bug]: Hermes App","url":null,"state":"closed"},{"kind":"issue","number":68163,"title":"bootstrap-installer: corrupt cached install.ps1 is trusted forever — every install fails at __manifest__ with parser errors until cache is manually deleted","url":null,"state":"closed"},{"kind":"pr","number":67021,"title":"fix(install): keep install.ps1 pure ASCII so Windows PowerShell 5.1 doesn't misparse it (#66994)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#67182","number":67182}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":67182,"title":"fix(install): keep install.ps1 pure ASCII so Windows PowerShell 5.1 doesn't misparse it (#66994/#67000)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/install.ps1","tests/test_install_ps1_ascii_only.py"],"fix_hunks":[{"file":"scripts/install.ps1","hunks":["@@ -443,7 +443,7 @@ function Get-PowerShellHostExe {\n }\n \n function Install-Uv {\n-    # Hermes owns its own uv at $HermesHome\\bin\\uv.exe.  Always install there —\n+    # Hermes owns its own uv at $HermesHome\\bin\\uv.exe.  Always install there --\n     # no PATH probing, no conda guards, no multi-location resolution chains.\n     # The runtime update path (hermes_cli/managed_uv.py) looks in the same\n     # place, so install.ps1 and `hermes update` stay in sync.","@@ -528,7 +528,7 @@ function Ensure-NodeExeOnPath {\n # prior process is not visible here.  Later stages (Test-Python,\n # Install-Venv, Install-Dependencies, Install-PlatformSdks) call this\n # at the top to populate $script:UvCmd from the managed location.\n-# Throws if uv is not findable — the caller's stage then surfaces a\n+# Throws if uv is not findable -- the caller's stage then surfaces a\n # clean error via the stage-driver's try/catch.\n function Resolve-UvCmd {\n     # Already resolved (default invocation path: Install-Uv ran earlier","@@ -544,7 +544,7 @@ function Resolve-UvCmd {\n         # Stale; fall through to re-discover.\n     }\n \n-    # Check the managed location first — this is where Install-Uv puts it.\n+    # Check the managed location first -- this is where Install-Uv puts it.\n     $managedUv = Join-Path $HermesHome \"bin\\uv.exe\"\n     if (Test-Path $managedUv) {\n         $script:UvCmd = $managedUv","@@ -1516,7 +1516,7 @@ function Install-Repository {\n                     if ($LASTEXITCODE -ne 0) { throw \"git checkout $Branch failed (exit $LASTEXITCODE)\" }\n                     # Managed installs should follow origin/$Branch exactly. If\n                     # the checkout has diverged (or has local-only commits),\n-                    # ff-only pull cannot succeed — mirror ``hermes update`` and\n+                    # ff-only pull cannot succeed -- mirror ``hermes update`` and\n                     # reset to the fetched remote so bootstrap/install can recover.\n                     git -c windows.appendAtomically=false pull --ff-only origin $Branch\n                     if ($LASTEXITCODE -ne 0) {","@@ -1573,11 +1573,11 @@ function Install-Repository {\n                                 Write-Host \"\"\n                                 Write-Host \"Conflicted files:\"\n                                 foreach ($file in $conflictedFiles) {\n-                                    Write-Host \"  • $file\"\n+                                    Write-Host \"  - $file\"\n                                 }\n                             }\n                             Write-Host \"\"\n-                            Write-Info \"Your stashed changes are preserved — nothing is lost.\"\n+                            Write-Info \"Your stashed changes are preserved -- nothing is lost.\"\n                             Write-Info \"  Stash ref: $autostashRef\"\n                             git -c windows.appendAtomically=false reset --hard HEAD 2>$null | Out-Null\n                             Write-Info \"Working tree reset to clean state.\"","@@ -1798,8 +1798,8 @@ function Install-Venv {\n             # /End stops a running task instance; /Change /DISABLE stops it\n             # from re-firing mid-install. (The Startup-folder .vbs fallback is\n             # NOT touched: it only fires at logon, so it cannot respawn a\n-            # gateway mid-install.) Re-enabled in the finally below — including\n-            # on failure — but only for tasks that were enabled to begin with.\n+            # gateway mid-install.) Re-enabled in the finally below -- including\n+            # on failure -- but only for tasks that were enabled to begin with.\n             # Best-effort: a missing task just errors quietly.\n             try {\n                 schtasks /Query /FO CSV 2>$null | ConvertFrom-Csv | Where-Object { $_.TaskName -like '*Hermes_Gateway*' } | ForEach-Object {","@@ -1893,7 +1893,7 @@ function Install-Venv {\n     }\n \n     # Clean up parked venvs from previous installs whose handles have since\n-    # been released. Best-effort — a still-held tree just stays for next time.\n+    # been released. Best-effort -- a still-held tree just stays for next time.\n     Get-ChildItem -Directory -Filter \"venv.stale.*\" -ErrorAction SilentlyContinue | ForEach-Object {\n         Remove-Item -Recurse -Force $_.FullName -ErrorAction SilentlyContinue\n     }","@@ -1926,10 +1926,10 @@ function Install-Venv {\n     } finally {\n         Pop-Location\n         # Re-arm the gateway autostart tasks disabled during the venv teardown\n-        # — in a finally so a failed teardown/creation can never strand the\n+        # -- in a finally so a failed teardown/creation can never strand the\n         # user's gateway autostart in the disabled state. Same function scope,\n         # so the list survives even under the stage-per-process bootstrap.\n-        # Deliberately NOT started here — dependencies aren't installed yet;\n+        # Deliberately NOT started here -- dependencies aren't installed yet;\n         # the task fires normally on next logon and `hermes update` / the\n         # gateway resume path handles the immediate restart.\n         if ($gatewayTasksDisabled -and $gatewayTasksDisabled.Count -gt 0) {","@@ -2247,7 +2247,7 @@ function Set-PathVariable {\n function Write-BootstrapMarker {\n     # Writes $InstallDir\\.hermes-bootstrap-complete which tells the Hermes\n     # desktop app (apps/desktop/electron/main.ts) \"install.ps1 ran\n-    # successfully — DON'T trigger the legacy first-launch bootstrap\n+    # successfully -- DON'T trigger the legacy first-launch bootstrap\n     # runner.\"\n     #\n     # Schema mirrors what main.ts's writeBootstrapMarker() / isBootstrap","@@ -2268,7 +2268,7 @@ function Write-BootstrapMarker {\n     # Resolve the pinned commit: explicit -Commit wins, otherwise read\n     # the checkout's HEAD via git. If git can't run, leave commit empty\n     # and the marker will fail desktop validation (pinnedCommit.length\n-    # >= 7) — better to be invalid than wrong.\n+    # >= 7) -- better to be invalid than wrong.\n     $pinnedCommit = $Commit\n     if (-not $pinnedCommit) {\n         # PS 5.1 doesn't support the ?. null-conditional operator, so","@@ -2283,7 +2283,7 @@ function Write-BootstrapMarker {\n                     $pinnedCommit = $resolved.Trim()\n                 }\n             } catch {\n-                # Ignore — pinnedCommit stays empty, marker stays invalid,\n+                # Ignore -- pinnedCommit stays empty, marker stays invalid,\n                 # desktop falls through to its legacy bootstrap path.\n             } finally {\n                 Pop-Location","@@ -2302,7 +2302,7 @@ function Write-BootstrapMarker {\n         pinnedCommit  = $pinnedCommit\n         pinnedBranch  = $pinnedBranch\n         completedAt   = (Get-Date).ToUniversalTime().ToString(\"yyyy-MM-ddTHH:mm:ss.fffZ\")\n-        # desktopVersion field intentionally omitted — only the desktop\n+        # desktopVersion field intentionally omitted -- only the desktop\n         # app knows its own version, and the marker validator doesn't\n         # require it. The desktop fills it in if/when it writes its\n         # own marker (e.g. after a future in-app upgrade).","@@ -2311,7 +2311,7 @@ function Write-BootstrapMarker {\n \n     # Write WITHOUT a UTF-8 BOM. PowerShell 5.1's `Set-Content -Encoding UTF8`\n     # always emits a BOM, and Node's plain JSON.parse rejects the BOM as an\n-    # unexpected character — so a BOM'd marker would silently fail the\n+    # unexpected character -- so a BOM'd marker would silently fail the\n     # desktop's readJson(), make isBootstrapComplete() return null, and the\n     # desktop would re-run the legacy bootstrap runner anyway. Defeats the\n     # whole point. Use the .NET API directly for BOM-less UTF-8.","@@ -2411,7 +2411,7 @@ function Install-NodeDeps {\n         # Cross-process driver mode (Hermes-Setup.exe runs each -Stage NAME\n         # in a fresh powershell.exe) means $script:HasNode set by Stage-Node\n         # in the previous process isn't visible here. Re-probe rather than\n-        # trust the stale global — Stage-Node already ran successfully or\n+        # trust the stale global -- Stage-Node already ran successfully or\n         # the bootstrap would've aborted, so npm is reachable.\n         if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {\n             Write-Info \"Skipping Node.js dependencies (Node not installed)\"","@@ -2688,7 +2688,7 @@ function Clear-ElectronBuildCache {\n # Last-resort Electron mirror after GitHub download fails (#47266).\n $script:DesktopElectronFallbackMirror = \"https://npmmirror.com/mirrors/electron/\"\n \n-# Electron package dir — workspace-local nest first, then root hoist.\n+# Electron package dir -- workspace-local nest first, then root hoist.\n function Get-ElectronDir {\n     param([string]$InstallDir)\n     $desktopLocal = Join-Path $InstallDir 'apps\\desktop\\node_modules\\electron'","@@ -2769,7 +2769,7 @@ function Install-Desktop {\n     #\n     # The Tauri bootstrap installer's launch_hermes_desktop command\n     # resolves apps/desktop/release/win-unpacked/Hermes.exe directly,\n-    # so an \"unpacked\" build (electron-builder --dir) is enough — we\n+    # so an \"unpacked\" build (electron-builder --dir) is enough -- we\n     # don't need to produce an NSIS/MSI artifact here.\n \n     # Always re-resolve Node here. Stages run in separate PowerShell processes,","@@ -2823,7 +2823,7 @@ function Install-Desktop {\n         #\n         # The streaming sink in bootstrap.rs's run_install_script\n         # captures every stdout/stderr line as it's emitted, so we don't\n-        # need a side TEMP log file — the installer's bootstrap log\n+        # need a side TEMP log file -- the installer's bootstrap log\n         # IS the artifact a support engineer reads.\n         #\n         # Prefer `npm ci`: it wipes node_modules and reinstalls from the","@@ -2878,7 +2878,7 @@ function Install-Desktop {\n     # NOT signing the output. Combined with signAndEditExecutable=false in\n     # apps/desktop/package.json's build.win block, electron-builder never\n     # invokes signtool and therefore never fetches/extracts winCodeSign\n-    # (whose macOS symlinks crash 7-Zip on non-admin Windows — a dead end we\n+    # (whose macOS symlinks crash 7-Zip on non-admin Windows -- a dead end we\n     # are NOT trying to work around). The Hermes icon + product name are\n     # stamped onto Hermes.exe by our own rcedit step (Set-DesktopExeIdentity)\n     # AFTER this build, completely decoupled from electron-builder signing.","@@ -2949,7 +2949,7 @@ function Install-Desktop {\n         Pop-Location\n         throw\n     } finally {\n-        # Restore env to whatever the caller had — don't leak our\n+        # Restore env to whatever the caller had -- don't leak our\n         # signing-off override into anything install.ps1 invokes later\n         # (Stage-PlatformSdks, etc.).\n         $env:CSC_IDENTITY_AUTO_DISCOVERY = $prevCSCAuto","@@ -2980,7 +2980,7 @@ function Install-Desktop {\n \n     # 3b. The Hermes icon + identity are stamped onto Hermes.exe by the\n     #     electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.mjs)\n-    #     during `npm run pack` above — for every build, so the installer's\n+    #     during `npm run pack` above -- for every build, so the installer's\n     #     --update rebuild stays branded too. No separate stamp step needed here.\n     #     electron-builder's own rcedit step stays disabled (signAndEditExecutable\n     #     =false) because enabling it drags in signtool -> winCodeSign -> the","@@ -2990,7 +2990,7 @@ function Install-Desktop {\n     #     directory. Chromium's GPU/renderer sandboxes CHECK-fail with\n     #     0x80000003 when this ACE is missing alongside orphan AppContainer\n     #     SIDs under %LOCALAPPDATA% (electron/electron#51761, hermes-agent#38216).\n-    #     Best-effort — never fail an otherwise-good install over ACL repair.\n+    #     Best-effort -- never fail an otherwise-good install over ACL repair.\n     try {\n         $appDir = Split-Path -Parent $desktopExe\n         & icacls $appDir /grant \"*S-1-15-2-2:(OI)(CI)(RX)\" /T /C /Q | Out-Null","@@ -3006,7 +3006,7 @@ function Install-Desktop {\n     # 4. Create Start Menu + Desktop shortcuts pointing DIRECTLY at the packed\n     #    Hermes.exe. We deliberately do NOT point them at `hermes desktop`: that\n     #    command rebuilds (npm install + electron-builder) on every launch,\n-    #    which would cost minutes each time. The packed exe is the consumer —\n+    #    which would cost minutes each time. The packed exe is the consumer --\n     #    launching it directly is instant, and updates flow through the\n     #    installer's --update path (which rebuilds once, then relaunches).\n     New-DesktopShortcuts -TargetExe $desktopExe","@@ -3062,11 +3062,11 @@ function New-DesktopShortcuts {\n         # cached bitmap. Critical on the --update path: the exe was re-stamped\n         # with the Hermes icon, but without this the shortcut can keep drawing\n         # the old Electron icon until the user manually refreshes / reboots.\n-        # Best-effort and silent — never fail the install over a cosmetic cache.\n+        # Best-effort and silent -- never fail the install over a cosmetic cache.\n         try {\n             & ie4uinit.exe -show 2>$null\n         } catch {\n-            # ie4uinit may be absent/renamed on some SKUs — ignore.\n+            # ie4uinit may be absent/renamed on some SKUs -- ignore.\n         }\n     } catch {\n         Write-Warn \"Skipping shortcut creation: $($_.Exception.Message)\""]},{"file":"tests/test_install_ps1_ascii_only.py","hunks":["@@ -0,0 +1,55 @@\n+\"\"\"Regression: install.ps1 must stay pure ASCII.\n+\n+Issues #66994 / #67000 reported the Windows GUI installer (Hermes-Setup.exe)\n+crashing before it did anything, with a cascade of PowerShell parser errors at\n+lines 1619 / 1770 (\"Missing argument in parameter list\", \"A 'using' statement\n+must appear before any other statements in a script\").\n+\n+Root cause: ``scripts/install.ps1`` has no UTF-8 BOM, and a commit added two\n+non-ASCII characters *inside double-quoted string literals* (a bullet and an\n+em-dash). Windows PowerShell 5.1 -- which the bootstrap runs the cached script\n+under -- reads a BOM-less ``.ps1`` in the system ANSI code page (e.g. CP1252),\n+not UTF-8. The em-dash's UTF-8 tail byte (0x94) decodes to a \"smart\" close-quote\n+(U+201D), which the PowerShell tokenizer treats as a string delimiter. That\n+prematurely closes the string and desyncs the parser for the rest of the file,\n+surfacing as unrelated syntax errors far downstream.\n+\n+Non-ASCII bytes inside ``#`` comments are harmless (the tokenizer skips a\n+comment to end-of-line regardless of what it contains), which is why the file\n+carried em-dashes in comments for months without breaking -- only a non-ASCII\n+byte in *code* (a string literal) triggers the desync.\n+\n+This test is source-level because Linux CI cannot execute the PowerShell\n+installer. Keeping the whole file ASCII-only is the transport-independent\n+invariant: pure ASCII cannot be misdecoded under any code page, BOM or not, so\n+the bug class cannot recur -- in a comment or a string.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from pathlib import Path\n+\n+\n+REPO_ROOT = Path(__file__).resolve().parent.parent\n+INSTALL_PS1 = REPO_ROOT / \"scripts\" / \"install.ps1\"\n+\n+\n+def test_install_ps1_is_pure_ascii() -> None:\n+    raw = INSTALL_PS1.read_bytes()\n+\n+    offenders = []\n+    line_no = 1\n+    for byte in raw:\n+        if byte == 0x0A:\n+            line_no += 1\n+        elif byte >= 0x80:\n+            offenders.append(line_no)\n+\n+    assert not offenders, (\n+        \"scripts/install.ps1 must be pure ASCII so Windows PowerShell 5.1 \"\n+        \"(which reads a BOM-less .ps1 in the system ANSI code page, not UTF-8) \"\n+        \"cannot misdecode a byte into a stray quote and desync the parser \"\n+        \"(issues #66994 / #67000). Non-ASCII bytes found on line(s): \"\n+        f\"{sorted(set(offenders))}. Use ASCII equivalents (em-dash -> '--', \"\n+        \"bullet -> '-').\"\n+    )\n"]}]},{"kind":"pr","number":67269,"title":"fix: Windows GUI installer non-English parsing (fixes #67193)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"97249cfc8","number":0},{"kind":"pr","value":"#67369","number":67369},{"kind":"pr","value":"#67214","number":67214}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":67330,"title":"fix(bootstrap): handle non-UTF-8 PowerShell output with lossy decoding","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#67369","number":67369},{"kind":"pr","value":"#67214","number":67214},{"kind":"pr","value":"#67214","number":67214}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":67369,"title":"fix(bootstrap): decode localized PS output + refresh mutable install cache (#67193, #67194)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"apps/bootstrap-installer/src-tauri/src/install_script.rs","hunks":["@@ -70,6 +70,29 @@ fn is_valid_commit(s: &str) -> bool {\n     (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())\n }\n \n+/// Resolver cache plan for a pin that already has a local path computed.\n+///\n+/// Immutable commit pins reuse cache forever. Mutable branch/tag pins always\n+/// refresh, and only fall back to a stale cache when the refresh fails.\n+#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n+pub(crate) enum CachePlan {\n+    /// On-disk hit for an immutable pin — skip the network.\n+    Reuse,\n+    /// Download (or re-download). `stale_ok` means a failed refresh may return\n+    /// the existing cache file (mutable pins with a prior download).\n+    Fetch { stale_ok: bool },\n+}\n+\n+pub(crate) fn cache_plan(immutable: bool, cached_exists: bool) -> CachePlan {\n+    if immutable && cached_exists {\n+        CachePlan::Reuse\n+    } else {\n+        CachePlan::Fetch {\n+            stale_ok: !immutable && cached_exists,\n+        }\n+    }\n+}\n+\n /// Resolves the install script to use for this run.\n ///\n /// `pin` is the commit-or-branch from either Hermes-Setup's build-time","@@ -100,9 +123,13 @@ pub async fn resolve(\n     // 2. (Not implemented) bundled fallback.\n \n     // 3. Network. Pin must be a real commit or a branch ref.\n-    let commit_or_ref = match (&pin.commit, &pin.branch) {\n-        (Some(c), _) if is_valid_commit(c) => c.clone(),\n-        (_, Some(b)) if !b.trim().is_empty() => b.clone(),\n+    //\n+    // Commit SHAs are immutable — permanent cache reuse is safe.\n+    // Branch/tag pins are moving refs: always try to refresh so \"Retry install\"\n+    // cannot keep reusing a poisoned install-main.ps1 forever (#67193).\n+    let (commit_or_ref, immutable) = match (&pin.commit, &pin.branch) {\n+        (Some(c), _) if is_valid_commit(c) => (c.clone(), true),\n+        (_, Some(b)) if !b.trim().is_empty() => (b.clone(), false),\n         (Some(other), _) => {\n             return Err(anyhow!(\n                 \"install script pin commit `{other}` is not a valid git SHA\"","@@ -116,36 +143,60 @@ pub async fn resolve(\n     };\n \n     let cached = cached_path(kind, &commit_or_ref);\n-    if cached.exists() {\n-        emit_log(&format!(\n-            \"[bootstrap] using cached {} for {}\",\n-            kind.filename(),\n-            truncate_ref(&commit_or_ref)\n-        ));\n-        return Ok(ResolvedScript {\n-            path: cached,\n-            source: ScriptSource::Cached,\n-            commit: pin.commit.clone(),\n-            branch: pin.branch.clone(),\n-        });\n-    }\n-\n-    emit_log(&format!(\n-        \"[bootstrap] downloading {} for {} from GitHub\",\n-        kind.filename(),\n-        truncate_ref(&commit_or_ref)\n-    ));\n-\n-    download(kind, &commit_or_ref, &cached).await?;\n-\n-    emit_log(&format!(\"[bootstrap] cached to {}\", cached.display()));\n+    match cache_plan(immutable, cached.exists()) {\n+        CachePlan::Reuse => {\n+            emit_log(&format!(\n+                \"[bootstrap] using cached {} for {}\",\n+                kind.filename(),\n+                truncate_ref(&commit_or_ref)\n+            ));\n+            return Ok(ResolvedScript {\n+                path: cached,\n+                source: ScriptSource::Cached,\n+                commit: pin.commit.clone(),\n+                branch: pin.branch.clone(),\n+            });\n+        }\n+        CachePlan::Fetch { stale_ok } => {\n+            emit_log(&format!(\n+                \"[bootstrap] downloading {} for {} {} from GitHub\",\n+                kind.filename(),\n+                if immutable {\n+                    \"commit\"\n+                } else {\n+                    \"mutable ref\"\n+                },\n+                truncate_ref(&commit_or_ref)\n+            ));\n \n-    Ok(ResolvedScript {\n-        path: cached,\n-        source: ScriptSource::Downloaded,\n-        commit: pin.commit.clone(),\n-        branch: pin.branch.clone(),\n-    })\n+            match download(kind, &commit_or_ref, &cached).await {\n+                Ok(()) => {\n+                    emit_log(&format!(\"[bootstrap] cached to {}\", cached.display()));\n+                    Ok(ResolvedScript {\n+                        path: cached,\n+                        source: ScriptSource::Downloaded,\n+                        commit: pin.commit.clone(),\n+                        branch: pin.branch.clone(),\n+                    })\n+                }\n+                Err(err) if stale_ok => {\n+                    emit_log(&format!(\n+                        \"[bootstrap] WARNING: refresh failed for mutable ref {}; using stale cached {} at {}: {err:#}\",\n+                        truncate_ref(&commit_or_ref),\n+                        kind.filename(),\n+                        cached.display()\n+                    ));\n+                    Ok(ResolvedScript {\n+                        path: cached,\n+                        source: ScriptSource::Cached,\n+                        commit: pin.commit.clone(),\n+                        branch: pin.branch.clone(),\n+                    })\n+                }\n+                Err(err) => Err(err),\n+            }\n+        }\n+    }\n }\n \n #[derive(Debug, Clone, Default)]","@@ -185,6 +236,33 @@ fn truncate_ref(s: &str) -> &str {\n     }\n }\n \n+/// UTF-8 BOM. Windows PowerShell 5.1 reads a BOM-less `.ps1` using the system\n+/// ANSI code page; a leading BOM is what tells it the file is UTF-8. The\n+/// `irm | iex` / `[scriptblock]::Create` path strips BOMs on purpose, but the\n+/// GUI bootstrap runs the *cached file* via `-File`, so we write the opposite\n+/// (#67193).\n+const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];\n+\n+/// Prepare bytes for the on-disk bootstrap cache.\n+///\n+/// `.ps1` files get a UTF-8 BOM (unless one is already present). `.sh` files\n+/// are left unchanged — a BOM would break `#!/bin/bash`.\n+pub(crate) fn prepare_cached_script_bytes(kind: ScriptKind, bytes: &[u8]) -> Vec<u8> {\n+    match kind {\n+        ScriptKind::Ps1 => {\n+            if bytes.starts_with(UTF8_BOM) {\n+                bytes.to_vec()\n+            } else {\n+                let mut out = Vec::with_capacity(UTF8_BOM.len() + bytes.len());\n+                out.extend_from_slice(UTF8_BOM);\n+                out.extend_from_slice(bytes);\n+                out\n+            }\n+        }\n+        ScriptKind::Sh => bytes.to_vec(),\n+    }\n+}\n+\n /// Downloads to `dest_path` via reqwest with rustls. Atomically renames\n /// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache.\n 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\n         .bytes()\n         .await\n         .with_context(|| format!(\"reading body of {url}\"))?;\n+    let bytes = prepare_cached_script_bytes(kind, &bytes);\n \n     let mut file = tokio::fs::File::create(&tmp_path)\n         .await","@@ -270,4 +349,58 @@ mod tests {\n         assert_eq!(sanitize_ref(\"main\"), \"main\");\n         assert_eq!(sanitize_ref(\"release/1.2.3\"), \"release_1.2.3\");\n     }\n+\n+    #[test]\n+    fn prepare_cached_ps1_prefixes_utf8_bom() {\n+        let out = prepare_cached_script_bytes(ScriptKind::Ps1, b\"Write-Host hi\\n\");\n+        assert!(out.starts_with(UTF8_BOM), \"cached .ps1 must start with UTF-8 BOM\");\n+        assert_eq!(&out[UTF8_BOM.len()..], b\"Write-Host hi\\n\");\n+    }\n+\n+    #[test]\n+    fn prepare_cached_ps1_does_not_double_bom() {\n+        let mut already = UTF8_BOM.to_vec();\n+        already.extend_from_slice(b\"x\");\n+        let out = prepare_cached_script_bytes(ScriptKind::Ps1, &already);\n+        assert_eq!(out, already);\n+        assert_eq!(out.windows(3).filter(|w| *w == UTF8_BOM).count(), 1);\n+    }\n+\n+    #[test]\n+    fn prepare_cached_sh_stays_bomless() {\n+        let out = prepare_cached_script_bytes(ScriptKind::Sh, b\"#!/bin/bash\\n\");\n+        assert!(!out.starts_with(UTF8_BOM));\n+        assert_eq!(out, b\"#!/bin/bash\\n\");\n+    }\n+\n+    #[test]\n+    fn commit_pins_are_immutable_branch_pins_are_not() {\n+        // Mirrors the resolve() immutable decision: SHA pins may reuse cache\n+        // forever; branch pins must refresh so Retry cannot keep a bad script.\n+        assert!(is_valid_commit(\"02d26981d3d4ad50e142399b8476f59ad5953ff0\"));\n+        assert!(!is_valid_commit(\"main\"));\n+        assert!(!is_valid_commit(\"release/1.2.3\"));\n+    }\n+\n+    #[test]\n+    fn existing_branch_cache_plans_refresh_with_stale_fallback() {\n+        // Resolver-level: a prior install-main.ps1 must not short-circuit\n+        // Retry — mutable pins refresh, and only fall back if download fails.\n+        assert_eq!(\n+            cache_plan(/*immutable=*/ false, /*cached_exists=*/ true),\n+            CachePlan::Fetch { stale_ok: true }\n+        );\n+        assert_eq!(\n+            cache_plan(/*immutable=*/ true, /*cached_exists=*/ true),\n+            CachePlan::Reuse\n+        );\n+        assert_eq!(\n+            cache_plan(/*immutable=*/ false, /*cached_exists=*/ false),\n+            CachePlan::Fetch { stale_ok: false }\n+        );\n+        assert_eq!(\n+            cache_plan(/*immutable=*/ true, /*cached_exists=*/ false),\n+            CachePlan::Fetch { stale_ok: false }\n+        );\n+    }\n }"]},{"file":"apps/bootstrap-installer/src-tauri/src/powershell.rs","hunks":["@@ -13,6 +13,103 @@ use tokio::io::{AsyncBufReadExt, BufReader};\n use tokio::process::{Child, Command};\n use tokio::sync::mpsc;\n \n+/// CP1252 mapping for bytes `0x80..=0x9F` (the range that differs from Latin-1).\n+/// Undefined slots keep the C1 control code points, matching Windows-1252\n+/// best-fit behavior used by `encoding_rs::WINDOWS_1252`.\n+const CP1252_80_9F: [char; 32] = [\n+    '\\u{20AC}', // 0x80 €\n+    '\\u{0081}', // 0x81\n+    '\\u{201A}', // 0x82 ‚\n+    '\\u{0192}', // 0x83 ƒ\n+    '\\u{201E}', // 0x84 „\n+    '\\u{2026}', // 0x85 …\n+    '\\u{2020}', // 0x86 †\n+    '\\u{2021}', // 0x87 ‡\n+    '\\u{02C6}', // 0x88 ˆ\n+    '\\u{2030}', // 0x89 ‰\n+    '\\u{0160}', // 0x8A Š\n+    '\\u{2039}', // 0x8B ‹\n+    '\\u{0152}', // 0x8C Œ\n+    '\\u{008D}', // 0x8D\n+    '\\u{017D}', // 0x8E Ž\n+    '\\u{008F}', // 0x8F\n+    '\\u{0090}', // 0x90\n+    '\\u{2018}', // 0x91 ‘\n+    '\\u{2019}', // 0x92 ’\n+    '\\u{201C}', // 0x93 “\n+    '\\u{201D}', // 0x94 ”\n+    '\\u{2022}', // 0x95 •\n+    '\\u{2013}', // 0x96 –\n+    '\\u{2014}', // 0x97 —\n+    '\\u{02DC}', // 0x98 ˜\n+    '\\u{2122}', // 0x99 ™\n+    '\\u{0161}', // 0x9A š\n+    '\\u{203A}', // 0x9B ›\n+    '\\u{0153}', // 0x9C œ\n+    '\\u{009D}', // 0x9D\n+    '\\u{017E}', // 0x9E ž\n+    '\\u{0178}', // 0x9F Ÿ\n+];\n+\n+fn decode_cp1252_byte(b: u8) -> char {\n+    match b {\n+        0x00..=0x7F => b as char,\n+        0x80..=0x9F => CP1252_80_9F[(b - 0x80) as usize],\n+        // 0xA0..=0xFF match Unicode Latin-1 / Windows-1252.\n+        _ => b as char,\n+    }\n+}\n+\n+/// Decode one stdout/stderr line from a child process.\n+///\n+/// Tokio's `BufReader::lines()` requires valid UTF-8 and aborts the line (with\n+/// `stream did not contain valid UTF-8`) at the first accented byte. Windows\n+/// PowerShell 5.1 emits localized ParserError text in the console ANSI code\n+/// page (often CP1252), so Portuguese/Spanish/etc. users only saw a truncated\n+/// `No` instead of `Não foi fornecido o terminador...` (#67193).\n+///\n+/// Prefer UTF-8 when the bytes are valid; otherwise decode as Windows-1252 so\n+/// both Western-European letters and CP1252-only punctuation (e.g. `0x91` →\n+/// U+2018) survive rather than disappearing into a read-error warning.\n+pub(crate) fn decode_console_bytes(bytes: &[u8]) -> String {\n+    match std::str::from_utf8(bytes) {\n+        Ok(s) => s.to_string(),\n+        Err(_) => bytes.iter().copied().map(decode_cp1252_byte).collect(),\n+    }\n+}\n+\n+/// Read one line (LF or CRLF) and decode it with [`decode_console_bytes`].\n+/// Returns `Ok(None)` on EOF with no bytes pending.\n+pub(crate) async fn read_decoded_line<R>(\n+    reader: &mut R,\n+    buf: &mut Vec<u8>,\n+) -> std::io::Result<Option<String>>\n+where\n+    R: AsyncBufReadExt + Unpin,\n+{\n+    // Cancel-safety: `buf` is NOT cleared on entry. When this future is\n+    // dropped mid-read inside `tokio::select!` (the other stream produced a\n+    // line first), `read_until` has already appended any consumed bytes to\n+    // `buf`; the next call resumes and appends the rest of the line. Clearing\n+    // on entry would silently drop those bytes. We clear only after a full\n+    // line has been decoded.\n+    let n = reader.read_until(b'\\n', buf).await?;\n+    if n == 0 && buf.is_empty() {\n+        return Ok(None);\n+    }\n+    // n == 0 with a non-empty buf means EOF cut off an unterminated line\n+    // (possibly accumulated across cancelled reads) -- emit it.\n+    if buf.last() == Some(&b'\\n') {\n+        buf.pop();\n+        if buf.last() == Some(&b'\\r') {\n+            buf.pop();\n+        }\n+    }\n+    let line = decode_console_bytes(buf);\n+    buf.clear();\n+    Ok(Some(line))\n+}\n+\n /// Hooks the caller installs to receive output.\n pub struct StreamSink {\n     pub on_stdout_line: Box<dyn Fn(&str) + Send + Sync>,","@@ -77,8 +174,13 @@ pub async fn run_script(\n     let stdout = child.stdout.take().expect(\"stdout was piped\");\n     let stderr = child.stderr.take().expect(\"stderr was piped\");\n \n-    let mut stdout_reader = BufReader::new(stdout).lines();\n-    let mut stderr_reader = BufReader::new(stderr).lines();\n+    // Byte-oriented readers + [`decode_console_bytes`]: do NOT use\n+    // `BufReader::lines()`, which requires valid UTF-8 and hides localized\n+    // PowerShell errors on non-English Windows (#67193).\n+    let mut stdout_reader = BufReader::new(stdout);\n+    let mut stderr_reader = BufReader::new(stderr);\n+    let mut stdout_buf = Vec::new();\n+    let mut stderr_buf = Vec::new();\n \n     let mut combined_stdout = String::new();\n     let mut combined_stderr = String::new();","@@ -87,7 +189,7 @@ pub async fn run_script(\n     // Loop: poll stdout, stderr, cancel, and child exit concurrently.\n     loop {\n         tokio::select! {\n-            line = stdout_reader.next_line() => {\n+            line = read_decoded_line(&mut stdout_reader, &mut stdout_buf) => {\n                 match line {\n                     Ok(Some(l)) => {\n                         (sink.on_stdout_line)(&l);","@@ -104,7 +206,7 @@ pub async fn run_script(\n                     }\n                 }\n             }\n-            line = stderr_reader.next_line() => {\n+            line = read_decoded_line(&mut stderr_reader, &mut stderr_buf) => {\n                 match line {\n                     Ok(Some(l)) => {\n                         (sink.on_stderr_line)(&l);","@@ -130,12 +232,12 @@ pub async fn run_script(\n     }\n \n     // Drain remaining lines after the loop exited.\n-    while let Ok(Some(l)) = stdout_reader.next_line().await {\n+    while let Ok(Some(l)) = read_decoded_line(&mut stdout_reader, &mut stdout_buf).await {\n         (sink.on_stdout_line)(&l);\n         combined_stdout.push_str(&l);\n         combined_stdout.push('\\n');\n     }\n-    while let Ok(Some(l)) = stderr_reader.next_line().await {\n+    while let Ok(Some(l)) = read_decoded_line(&mut stderr_reader, &mut stderr_buf).await {\n         (sink.on_stderr_line)(&l);\n         combined_stderr.push_str(&l);\n         combined_stderr.push('\\n');","@@ -354,4 +456,98 @@ info line\n             \"unexpected powershell path: {normalized}\"\n         );\n     }\n+\n+    #[test]\n+    fn decode_console_bytes_keeps_valid_utf8() {\n+        assert_eq!(decode_console_bytes(\"café — ok\".as_bytes()), \"café — ok\");\n+    }\n+\n+    #[test]\n+    fn decode_console_bytes_preserves_cp1252_portuguese_error() {\n+        // \"Não foi fornecido o terminador...\" as Windows PowerShell 5.1 emits\n+        // under CP1252 (0xE3 = ã). BufReader::lines() previously failed here\n+        // with \"stream did not contain valid UTF-8\" and the UI only showed \"No\".\n+        let bytes: &[u8] = b\"N\\xE3o foi fornecido o terminador\";\n+        assert_eq!(decode_console_bytes(bytes), \"Não foi fornecido o terminador\");\n+    }\n+\n+    #[test]\n+    fn decode_console_bytes_maps_cp1252_only_punctuation() {\n+        // 0x91/0x92 are curly quotes in Windows-1252, but C1 controls under\n+        // Latin-1 (`b as char`). This locks the real CP1252 fallback.\n+        let bytes: &[u8] = b\"say \\x91hi\\x92\";\n+        assert_eq!(decode_console_bytes(bytes), \"say \\u{2018}hi\\u{2019}\");\n+        assert_ne!(\n+            decode_console_bytes(bytes),\n+            bytes.iter().map(|&b| b as char).collect::<String>(),\n+            \"Latin-1 byte mapping must not be used for the 0x80..=0x9F range\"\n+        );\n+    }\n+\n+    #[tokio::test]\n+    async fn read_decoded_line_survives_non_utf8_and_crlf() {\n+        let data: &[u8] = b\"N\\xE3o erro\\r\\nnext\\n\";\n+        let mut reader = BufReader::new(data);\n+        let mut buf = Vec::new();\n+        assert_eq!(\n+            read_decoded_line(&mut reader, &mut buf)\n+                .await\n+                .unwrap()\n+                .as_deref(),\n+            Some(\"Não erro\")\n+        );\n+        assert_eq!(\n+            read_decoded_line(&mut reader, &mut buf)\n+                .await\n+                .unwrap()\n+                .as_deref(),\n+            Some(\"next\")\n+        );\n+        assert!(read_decoded_line(&mut reader, &mut buf)\n+            .await\n+            .unwrap()\n+            .is_none());\n+    }\n+\n+    #[tokio::test]\n+    async fn read_decoded_line_preserves_partial_line_across_cancellation() {\n+        use std::time::Duration;\n+        use tokio::io::AsyncWriteExt;\n+\n+        let (mut tx, rx) = tokio::io::duplex(64);\n+        let mut reader = BufReader::new(rx);\n+        let mut buf = Vec::new();\n+\n+        tx.write_all(b\"partial\").await.unwrap();\n+        // Poll once, then cancel (drop) the future -- exactly what\n+        // tokio::select! does in run_script when the other stream produces\n+        // a line first. The consumed bytes must survive in `buf`.\n+        let _ = tokio::time::timeout(\n+            Duration::from_millis(0),\n+            read_decoded_line(&mut reader, &mut buf),\n+        )\n+        .await;\n+\n+        tx.write_all(b\" line\\n\").await.unwrap();\n+        let line = read_decoded_line(&mut reader, &mut buf).await.unwrap();\n+        assert_eq!(line.as_deref(), Some(\"partial line\"));\n+    }\n+\n+    #[tokio::test]\n+    async fn read_decoded_line_emits_unterminated_final_line_at_eof() {\n+        let data: &[u8] = b\"no trailing newline\";\n+        let mut reader = BufReader::new(data);\n+        let mut buf = Vec::new();\n+        assert_eq!(\n+            read_decoded_line(&mut reader, &mut buf)\n+                .await\n+                .unwrap()\n+                .as_deref(),\n+            Some(\"no trailing newline\")\n+        );\n+        assert!(read_decoded_line(&mut reader, &mut buf)\n+            .await\n+            .unwrap()\n+            .is_none());\n+    }\n }"]},{"file":"apps/bootstrap-installer/src-tauri/src/update.rs","hunks":["@@ -31,10 +31,11 @@ use std::time::{Duration, Instant};\n \n use anyhow::{anyhow, Result};\n use tauri::{AppHandle, Emitter};\n-use tokio::io::{AsyncBufReadExt, BufReader};\n+use tokio::io::BufReader;\n use tokio::process::Command;\n \n use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};\n+use crate::powershell::read_decoded_line;\n \n /// `hermes update` exit code meaning \"another hermes process is holding the\n /// venv shim open / dirty precondition\" — see _cmd_update_impl in","@@ -662,28 +663,31 @@ async fn run_streamed(\n \n     let stdout = child.stdout.take().expect(\"stdout piped\");\n     let stderr = child.stderr.take().expect(\"stderr piped\");\n-    let mut out = BufReader::new(stdout).lines();\n-    let mut err = BufReader::new(stderr).lines();\n+    // Same non-UTF-8-safe decode path as powershell::run_script (#67193).\n+    let mut out = BufReader::new(stdout);\n+    let mut err = BufReader::new(stderr);\n+    let mut out_buf = Vec::new();\n+    let mut err_buf = Vec::new();\n \n     let stage_owned = stage.map(|s| s.to_string());\n     loop {\n         tokio::select! {\n-            line = out.next_line() => match line {\n+            line = read_decoded_line(&mut out, &mut out_buf) => match line {\n                 Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l),\n                 Ok(None) => break,\n                 Err(e) => { tracing::warn!(\"stdout read error: {e}\"); break; }\n             },\n-            line = err.next_line() => match line {\n+            line = read_decoded_line(&mut err, &mut err_buf) => match line {\n                 Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l),\n                 Ok(None) => {}\n                 Err(e) => { tracing::warn!(\"stderr read error: {e}\"); }\n             },\n         }\n     }\n-    while let Ok(Some(l)) = out.next_line().await {\n+    while let Ok(Some(l)) = read_decoded_line(&mut out, &mut out_buf).await {\n         emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l);\n     }\n-    while let Ok(Some(l)) = err.next_line().await {\n+    while let Ok(Some(l)) = read_decoded_line(&mut err, &mut err_buf).await {\n         emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l);\n     }\n \n"]}]},{"kind":"pr","number":68180,"title":"fix(bootstrap): reject truncated install-script downloads via Content-Length (#68163)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"57b9de80ef","number":0},{"kind":"pr","value":"#68163","number":68163},{"kind":"pr","value":"#67193","number":67193}],"category":"wontfix_bydesign","implication":"wontfix"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}