Installer path-quoting bugs: spaces and special characters

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

Data as of

7 open items. Fix merged in #13001. Check it covers your case.

The short answer

2open issues
5open PRs
40items grouped
Known?
Yes. 40 related items grouped here, 12 issues already closed.
Fix event
A fix was merged to main in PR #13001. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #13001, #49838, #67192, #78682, #113919. That is separate from the fix event.
Still open
2 issues and 5 PRs.

These issues and fixes address installer failures caused by unquoted paths containing spaces (primarily on macOS) and Windows 8.3 short-path resolution problems when usernames contain dots or accented characters. The fixes span shell scripts, .env value handling, and Python/UV path references.

Open issues2

  • Issue#47822macOS install/bootstrap fails when HERMES_HOME path contains spaces
  • Issue#53086Install fails on macOS when home directory path contains spaces

Open PRs5

  • PR#38171fix(install): quote UV_CMD to handle spaces in home directory path (fixes #38163)
  • PR#47832fix(install): rewrite venv entrypoint shebangs when HERMES_HOME has spaces
  • PR#53126fix(installer): quote uv path invocations
  • PR#57249fix(install): quote AGENT_BROWSER_EXECUTABLE_PATH for spaced paths
  • PR#57449fix(installer): quote browser env override paths

Fixes on record

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

  • #13001 fix(install): quote PYTHON_PATH and UV_CMD for paths with spaces on macOS (#10009)

    merged to main

    files: scripts/install.sh, scripts/release.py

  • #49838 fix(install): expand 8.3 short %TEMP% so Windows Node/Electron stages don't abort

    merged to main

    files: scripts/install.ps1, scripts/tests/test-install-ps1-longpath.ps1

    Show the diff
    • scripts/install.ps1
      @@ -88,6 +88,50 @@ try {
           # Mojibake on output is then cosmetic-only, install still works.
       }
       
      +# ============================================================================
      +# 8.3 short-path normalization
      +# ============================================================================
      +# When the Windows user-profile folder name contains a space (e.g.
      +# "First Last"), Windows generates an 8.3 short alias for it (e.g. FIRST~1.LAS)
      +# and may expose %TEMP%/%TMP% in that short form:
      +#   C:\Users\FIRST~1.LAS\AppData\Local\Temp
      +# PowerShell's FileSystem provider mishandles the "~1.ext" component when such a
      +# path is handed to a provider cmdlet like `Tee-Object -FilePath` /
      +# `Out-File -FilePath`, throwing:
      +#   "An object at the specified path C:\Users\FIRST~1.LAS does not exist."
      +# Every Node/Electron build+install stage streams its log to %TEMP% via
      +# Tee-Object, so they all abort with that error, while the Python/uv stages --
      +# which never write a side log to %TEMP% through a provider cmdlet -- complete
      +# fine. Expanding %TEMP%/%TMP% back to their long form once, up front, lets
      +# every downstream cmdlet (and child process) see a path the provider can
      +# resolve. (GH: Windows desktop installer fails at Node/Electron stages.)
      +
      +function ConvertTo-LongPath {
      +    param([string]$Path)
      +    if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }
      +    # Only 8.3 short names carry a tilde+digit ("~1"); skip the COM round-trip
      +    # for ordinary long paths.
      +    if ($Path -notmatch '~\d') { return $Path }
      +    try {
      +        $fso = New-Object -ComObject Scripting.FileSystemObject
      +        if ($fso.FolderExists($Path)) { return $fso.GetFolder($Path).Path }
      +        if ($fso.FileExists($Path))   { return $fso.GetFile($Path).Path }
      +    } catch {
      +        # COM unavailable / locked-down host: fall back to the original path.
      +    }
      +    return $Path
      +}
      +
      +foreach ($tmpVar in @('TEMP', 'TMP')) {
      +    $current = [Environment]::GetEnvironmentVariable($tmpVar)
      +    if ($current) {
      +        $expanded = ConvertTo-LongPath $current
      +        if ($expanded -and $expanded -ne $current) {
      +            Set-Item -Path "Env:$tmpVar" -Value $expanded
      +        }
      +    }
      +}
      +
       # ============================================================================
       # Configuration
       # ============================================================================
    • scripts/tests/test-install-ps1-longpath.ps1
      @@ -0,0 +1,86 @@
      +# Unit tests for install.ps1's ConvertTo-LongPath helper.
      +#
      +# Run from a PowerShell prompt:
      +#
      +#   powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1
      +#
      +# Background: on a Windows profile whose folder name contains a space (e.g.
      +# "First Last"), %TEMP%/%TMP% can be exposed as an 8.3 short path
      +# (C:\Users\FIRST~1.LAS\...). PowerShell's FileSystem provider chokes on the
      +# "~1.ext" component when it reaches a provider cmdlet (Tee-Object -FilePath),
      +# aborting the Node/Electron install+build stages. install.ps1 expands such
      +# paths to their long form up front; this verifies the helper's contract.
      +#
      +# We extract just the function from install.ps1 via the AST so the installer's
      +# top-level body never runs (dot-sourcing would execute the whole script).
      +# The COM-backed expansion only fires for inputs containing "~<digit>"; the
      +# pass-through and graceful-fallback paths are assertable on any host (incl.
      +# non-Windows pwsh, where the COM object is simply unavailable).
      +
      +$ErrorActionPreference = "Stop"
      +$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
      +$installScript = Join-Path $repoRoot "scripts/install.ps1"
      +
      +if (-not (Test-Path $installScript)) {
      +    throw "Could not locate install.ps1 at $installScript"
      +}
      +
      +$failures = 0
      +function Assert-Equal {
      +    param([Parameter(Mandatory = $true)] $Expected,
      +          [Parameter(Mandatory = $true)] $Actual,
      +          [Parameter(Mandatory = $true)] [string]$Label)
      +    if ($Expected -ne $Actual) {
      +        Write-Host "FAIL: $Label" -ForegroundColor Red
      +        Write-Host "  expected: $Expected"
      +        Write-Host "  actual:   $Actual"
      +        $script:failures++
      +    } else {
      +        Write-Host "OK: $Label" -ForegroundColor Green
      +    }
      +}
      +
      +# --- Load ConvertTo-LongPath from install.ps1 without executing the script ---
      +$tokens = $null
      +$errors = $null
      +$ast = [System.Management.Automation.Language.Parser]::ParseFile($installScript, [ref]$tokens, [ref]$errors)
      +$fnAst = $ast.FindAll(
      +    {
      +        param($node)
      +        $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
      +        $node.Name -eq 'ConvertTo-LongPath'
      +    }, $true) | Select-Object -First 1
      +
      +if (-not $fnAst) {
      +    throw "ConvertTo-LongPath not found in install.ps1 -- did the helper get renamed/removed?"
      +}
      +. ([scriptblock]::Create($fnAst.Extent.Text))
      +
      +# --- Tests ---
      +Write-Host ""
      +Write-Host "-- ConvertTo-LongPath --"
      +
      +Assert-Equal -Expected "" -Actual (ConvertTo-LongPath "") -Label "empty string returns empty"
      +Assert-Equal -Expected $null -Actual (ConvertTo-LongPath $null) -Label "null returns null"
      +
      +# No 8.3 component -> returned verbatim (even with spaces).
      +$longish = "C:\Users\First Last\AppData\Local\Temp"
      +Assert-Equal -Expected $longish -Actual (ConvertTo-LongPath $longish) -Label "long path with spaces is unchanged"
      +
      +$noTilde = "/tmp/some/long/path"
      +Assert-Equal -Expected $noTilde -Actual (ConvertTo-LongPath $noTilde) -Label "tilde-free path is unchanged"
      +
      +# Looks like an 8.3 name but does not exist -> graceful fallback to the input
      +# (FolderExists/FileExists both false, or COM unavailable on this host).
      +$fakeShort = "C:\Users\FIRST~1.LAS\does\not\exist"
      +Assert-Equal -Expected $fakeShort -Actual (ConvertTo-LongPath $fakeShort) -Label "nonexistent 8.3 path falls back to input"
      +
      +# --- Summary ---
      +Write-Host ""
      +if ($failures -gt 0) {
      +    Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
      +    exit 1
      +} else {
      +    Write-Host "All ConvertTo-LongPath tests passed." -ForegroundColor Green
      +    exit 0
      +}
  • #67192 fix(config): P2 batch — .env quoting/UTF-16, aux key_env, profile-aware system prompt

    merged to main

    files: agent/auxiliary_client.py, agent/system_prompt.py, hermes_cli/config.py, hermes_cli/env_loader.py, tests/hermes_cli/test_config.py, tests/hermes_cli/test_env_loader.py

    Show the diff
    • agent/auxiliary_client.py
      @@ -6253,6 +6253,13 @@ def _resolve_task_provider_model(
               cfg_model = str(task_config.get("model", "")).strip() or None
               cfg_base_url = str(task_config.get("base_url", "")).strip() or None
               cfg_api_key = [redacted:secret-assignment]"api_key", "")).strip() or None
      +        # Resolve key_env → env var when api_key is not set directly
      +        if not cfg_api_key:
      +            cfg_key_env = str(
      +                task_config.get("key_env") or task_config.get("api_key_env") or ""
      +            ).strip()
      +            if cfg_key_env:
      +                cfg_api_key = [redacted:secret-assignment] "").strip() or None
               cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None
       
           # 'auto' is a sentinel meaning "inherit from main runtime / auto-detect", not
    • agent/system_prompt.py
      @@ -46,6 +46,7 @@
           drain_truncation_warnings,
       )
       from agent.runtime_cwd import resolve_context_cwd
      +from hermes_constants import get_hermes_home
       from utils import is_truthy_value
       
       
      @@ -395,7 +396,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
           if active_profile == "default":
               stable_parts.append(
                   "Active Hermes profile: default. Other profiles (if any) live "
      -            "under ~/.hermes/profiles/<name>/. Each profile has its own "
      +            "under " + str(get_hermes_home()) + "/profiles/<name>/. Each profile has its own "
                   "skills/, plugins/, cron/, and memories/ that affect a different "
                   "session than this one. Do not modify another profile's "
                   "skills/plugins/cron/memories unless the user explicitly directs "
      @@ -404,9 +405,9 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
           else:
               stable_parts.append(
                   f"Active Hermes profile: {active_profile}. This session reads "
      -            f"and writes ~/.hermes/profiles/{active_profile}/. The default "
      -            f"profile's data lives at ~/.hermes/skills/, ~/.hermes/plugins/, "
      -            f"~/.hermes/cron/, ~/.hermes/memories/ — those belong to a "
      +            f"and writes {get_hermes_home()}/profiles/{active_profile}/. The default "
      +            f"profile's data lives at {get_hermes_home()}/skills/, {get_hermes_home()}/plugins/, "
      +            f"{get_hermes_home()}/cron/, {get_hermes_home()}/memories/ — those belong to a "
                   f"different session run from a different shell. Do NOT modify "
                   f"another profile's skills/plugins/cron/memories unless the user "
                   f"explicitly directs you to. The cross-profile write guard will "
    • hermes_cli/config.py
      @@ -7829,11 +7829,16 @@ def _quote_env_value(value: str) -> str:
           """Quote .env values containing characters with special dotenv meaning."""
           if value == "":
               return value
      +    # Internal whitespace (space/tab/etc.) must be quoted so shell `set -a; . file`
      +    # word-splits don't break paths like macOS "Application Support". Leading/
      +    # trailing whitespace is already covered by the strip check; any() covers
      +    # internal runs that strip() would leave alone.
           needs_quoting = (
               "#" in value
               or '"' in value
               or "'" in value
               or value != value.strip()
      +        or any(c.isspace() for c in value)
           )
           if not needs_quoting:
               return value
    • hermes_cli/env_loader.py
      @@ -2,6 +2,8 @@
       
       from __future__ import annotations
       
      +import codecs
      +import io
       import os
       import sys
       from pathlib import Path
      @@ -21,6 +23,12 @@
       # tests) don't spam the same warning multiple times.
       _WARNED_KEYS: set[str] = set()
       
      +# Paths we've already emitted a UTF-32 refuse-to-mangle warning for.
      +# load_hermes_dotenv can call _sanitize_env_file_if_needed multiple times
      +# for the same file (user env + project env + hot-reload); once per path
      +# is enough.
      +_WARNED_UTF32_PATHS: set[str] = set()
      +
       # Map of env-var name → source label ("bitwarden", etc.) for credentials
       # that were injected by an external secret source during load_hermes_dotenv().
       # Used by setup / `hermes model` flows to label detected credentials so
      @@ -176,6 +184,12 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
           with ``ValueError: embedded null byte`` — typically introduced by
           copy-pasting API keys from terminals or rich-text editors.
       
      +    Encoding: sniffs a leading BOM *before* any text decode. UTF-16
      +    (Notepad "Unicode") is decoded correctly and rewritten as clean
      +    UTF-8. UTF-32 is refused (left untouched) so we never fall through
      +    to the errors=replace corruption path. Order of BOM checks matters:
      +    UTF-32-LE's BOM starts with UTF-16-LE's FF FE.
      +
           We delegate to ``hermes_cli.config._sanitize_env_lines`` which
           already knows all valid Hermes env-var names and can split
           concatenated lines correctly.
      @@ -187,16 +201,70 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
           except ImportError:
               return  # early bootstrap — config module not available yet
       
      -    read_kw = {"encoding": "utf-8-sig", "errors": "replace"}
           try:
      -        with open(path, **read_kw) as f:
      -            original = f.readlines()
      +        raw = path.read_bytes()
      +    except Exception:
      +        return
      +
      +    # Sniff leading BOM bytes BEFORE decoding. ORDER MATTERS:
      +    # codecs.BOM_UTF32_LE is FF FE 00 00, which startswith
      +    # codecs.BOM_UTF16_LE (FF FE). Checking UTF-16 first would
      +    # misdetect UTF-32-LE as UTF-16-LE and mangle the file.
      +    force_utf8_rewrite = False
      +    if raw.startswith(codecs.BOM_UTF32_LE) or raw.startswith(codecs.BOM_UTF32_BE):
      +        # Lazy import keeps the module import block identical to #65124's
      +        # codecs/io additions so the two PRs auto-merge either order.
      +        path_key = str(path.resolve())
      +        if path_key not in _WARNED_UTF32_PATHS:
      +            _WARNED_UTF32_PATHS.add(path_key)
      +            import logging
      +
      +            logging.getLogger(__name__).warning(
      +                "Skipping .env sanitize for %s: UTF-32 BOM detected; "
      +                "leaving file untouched to avoid corruption",
      +                path,
      +            )
      +        return
      +    if raw.startswith(codecs.BOM_UTF16_LE) or raw.startswith(codecs.BOM_UTF16_BE):
      +        # "utf-16" uses the BOM to select endianness and strips it.
      +        # TextIOWrapper + newline=None matches open()'s universal-newlines
      +        # line splitting (\\n/\\r\\n/\\r only — not splitlines()'s extra
      +        # Unicode boundaries like U+2028), so sanitize sees the same lines
      +        # as the UTF-8 path.
      +        try:
      +            with io.TextIOWrapper(
      +                io.BytesIO(raw), encoding="utf-16", newline=None
      +            ) as f:
      +                original = f.readlines()
      +        except UnicodeDecodeError:
      +            return
      +        # Source is UTF-16 on disk; always rewrite as clean UTF-8 so
      +        # the subsequent utf-8 dotenv load sees a canonical file.
      +        force_utf8_rewrite = True
      +    else:
      +        # Default path: utf-8-sig (strips UTF-8 BOM if present) with
      +        # errors=replace so embedded NULs can be stripped below.
      +        try:
      +            with open(path, encoding="utf-8-sig", errors="replace") as f:
      +                original = f.readlines()
      +        except Exception:
      +            return
      +        # Defense-in-depth: errors=replace turns undecodable leading
      +        # bytes into U+FFFD. Persisting that glues replacement chars
      +        # onto the first key name and rewrites the file permanently
      +        # (the UTF-16-with-BOM corruption path before BOM sniffing).
      +        # Leave the file untouched rather than write the mangling.
      +        if original and original[0].startswith("\ufffd"):
      +            return
      +
      +    try:
               # Strip null bytes before _sanitize_env_lines so they never
               # reach python-dotenv (which passes them to os.environ and
      -        # crashes with ValueError).
      +        # crashes with ValueError). Also intentionally repairs
      +        # BOM-less UTF-16 (NUL-padded ASCII) into clean UTF-8.
               stripped = [line.replace("\x00", "") for line in original]
               sanitized = _sanitize_env_lines(stripped)
      -        if sanitized != original:
      +        if sanitized != original or force_utf8_rewrite:
                   import tempfile
                   fd, tmp = tempfile.mkstemp(
                       dir=str(path.parent), suffix=".tmp", prefix=".env_"
    • tests/hermes_cli/test_config.py
      @@ -607,6 +607,210 @@ def test_save_env_value_updates_hash_value_with_quotes(self, tmp_path):
                   assert parsed["ANTHROPIC_TOKEN"] == token
                   assert load_env()["ANTHROPIC_TOKEN"] == token
       
      +    def test_save_env_value_quotes_values_with_internal_spaces(self, tmp_path):
      +        """Internal spaces must be quoted so shell-sourcing does not word-split.
      +
      +        Sibling of installer #57247: core writer left
      +        TERMINAL_SSH_KEY=/Users/.../Application Support/... unquoted.
      +        python-dotenv still parsed it; ``set -a; . file`` failed.
      +        """
      +        import subprocess
      +        from dotenv import dotenv_values
      +
      +        path = "/Users/paulo/Library/Application Support/hermes/keys/id_ed25519"
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
      +            os.environ.pop("TERMINAL_SSH_KEY", None)
      +            save_env_value("TERMINAL_SSH_KEY", path)
      +
      +            env_path = tmp_path / ".env"
      +            content = env_path.read_text(encoding="utf-8")
      +            assert f'TERMINAL_SSH_KEY="{path}"' in content
      +
      +            parsed = dotenv_values(str(env_path))
      +            assert parsed["TERMINAL_SSH_KEY"] == path
      +            assert load_env()["TERMINAL_SSH_KEY"] == path
      +
      +            # Shell source must round-trip (this is what the bug broke).
      +            r = subprocess.run(
      +                [
      +                    "env",
      +                    "-i",
      +                    "sh",
      +                    "-c",
      +                    f"set -a; . '{env_path}'; set +a; "
      +                    f'printf "%s" "$TERMINAL_SSH_KEY"',
      +                ],
      +                capture_output=True,
      +                text=True,
      +            )
      +            assert r.returncode == 0, r.stderr
      +            assert r.stderr == ""
      +            assert r.stdout == path
      +
      +    def test_save_env_value_quotes_values_with_tabs(self, tmp_path):
      +        """Tabs trigger quoting; round-trip via dotenv and shell source."""
      +        import subprocess
      +        from dotenv import dotenv_values
      +
      +        value = "left\tright"
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
      +            os.environ.pop("TABBY_KEY", None)
      +            save_env_value("TABBY_KEY", value)
      +
      +            env_path = tmp_path / ".env"
      +            content = env_path.read_text(encoding="utf-8")
      +            assert f'TABBY_KEY="{value}"' in content
      +
      +            parsed = dotenv_values(str(env_path))
      +            assert parsed["TABBY_KEY"] == value
      +            assert load_env()["TABBY_KEY"] == value
      +
      +            r = subprocess.run(
      +                [
      +                    "env",
      +                    "-i",
      +                    "sh",
      +                    "-c",
      +                    f"set -a; . '{env_path}'; set +a; "
      +                    f'printf "%s" "$TABBY_KEY"',
      +                ],
      +                capture_output=True,
      +                text=True,
      +            )
      +            assert r.returncode == 0, r.stderr
      +            assert r.stderr == ""
      +            assert r.stdout == value
      +
      +    def test_save_env_value_spaced_path_is_idempotent(self, tmp_path):
      +        """Saving the same spaced value twice must not grow quotes."""
      +        path = "/Users/me/Application Support/key"
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
      +            os.environ.pop("TERMINAL_SSH_KEY", None)
      +            save_env_value("TERMINAL_SSH_KEY", path)
      +            first = (tmp_path / ".env").read_text(encoding="utf-8")
      +            save_env_value("TERMINAL_SSH_KEY", path)
      +            second = (tmp_path / ".env").read_text(encoding="utf-8")
      +
      +            assert first == second
      +            assert first.count('TERMINAL_SSH_KEY="') == 1
      +            assert '""' not in first
      +            assert f'TERMINAL_SSH_KEY="{path}"' in first
      +
      +    def test_save_env_value_readback_resave_is_idempotent(self, tmp_path):
      +        """hermes setup path: dotenv unquotes, then re-save must not grow quotes."""
      +        from dotenv import dotenv_values
      +
      +        path = "/Users/me/Application Support/key"
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
      +            os.environ.pop("TERMINAL_SSH_KEY", None)
      +            save_env_value("TERMINAL_SSH_KEY", path)
      +            first = (tmp_path / ".env").read_text(encoding="utf-8")
      +
      +            # Real read-back boundary (what setup uses via get_env_value/dotenv).
      +            read_back = dotenv_values(str(tmp_path / ".env"))["TERMINAL_SSH_KEY"]
      +            assert read_back == path
      +            save_env_value("TERMINAL_SSH_KEY", read_back)
      +            second = (tmp_path / ".env").read_text(encoding="utf-8")
      +
      +            assert first == second
      +            assert f'TERMINAL_SSH_KEY="{path}"' in second
      +
      +    def test_save_env_value_strips_newlines_before_quoting(self, tmp_path):
      +        """save_env_value strips \\n/\\r before _quote_env_value; result is one line.
      +
      +        Pins the boundary so any(c.isspace()) never quotes multi-line dotenv
      +        values through this writer (newlines never reach the quoter).
      +        """
      +        from dotenv import dotenv_values
      +
      +        raw = "line1\nline2\rline3"
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
      +            os.environ.pop("MULTI_KEY", None)
      +            save_env_value("MULTI_KEY", raw)
      +
      +            content = (tmp_path / ".env").read_text(encoding="utf-8")
      +            # Single KEY= line, no embedded raw newlines in the value payload.
      +            lines = [ln for ln in content.splitlines() if ln.startswith("MULTI_KEY=")]
      +            assert len(lines) == 1
      +            assert "\n" not in lines[0]
      +            assert "\r" not in lines[0]
      +            # Newlines stripped -> "line1line2line3" has no whitespace -> unquoted.
      +            assert lines[0] == "MULTI_KEY=line1line2line3"
      +            parsed = dotenv_values(str(tmp_path / ".env"))
      +            assert parsed["MULTI_KEY"] == "line1line2line3"
      +
      +    def test_save_env_value_simple_values_stay_unquoted(self, tmp_path):
      +        """No quoting churn: plain values remain bare; untouched lines unchanged."""
      +        env_path = tmp_path / ".env"
      +        # Pre-existing lines: one simple, one already correctly bare.
      +        env_path.write_text(
      +            "KEEP_SIMPLE=plainvalue\n"
      +            "OTHER_KEY=foo123\n",
      +            encoding="utf-8",
      +        )
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
      +            os.environ.pop("NEW_KEY", None)
      +            os.environ.pop("KEEP_SIMPLE", None)
      +            save_env_value("NEW_KEY", "bar-simple")
      +
      +            content = env_path.read_text(encoding="utf-8")
      +            # Newly written simple value is unquoted.
      +            assert "NEW_KEY=bar-simple\n" in content
      +            assert 'NEW_KEY="' not in content
      +            # Untouched pre-existing simple lines are not re-quoted.
      +            assert "KEEP_SIMPLE=plainvalue\n" in content
      +            assert "OTHER_KEY=foo123\n" in content
      +            assert 'KEEP_SIMPLE="' not in content
      +            assert 'OTHER_KEY="' not in content
      +
      +    def test_save_env_value_does_not_requote_untouched_spaced_lines(self, tmp_path):
      +        """Mass-requote guard: rewriting another key leaves legacy spaced
      +        lines as-is (fix only applies when that key is saved again).
      +        """
      +        env_path = tmp_path / ".env"
      +        legacy = (
      +            "TERMINAL_SSH_KEY=/Users/me/Application Support/key\n"
      +            "PLAIN=ok\n"
      +        )
      +        env_path.write_text(legacy, encoding="utf-8")
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
      +            os.environ.pop("PLAIN", None)
      +            save_env_value("PLAIN", "ok2")
      +
      +            content = env_path.read_text(encoding="utf-8")
      +            # Legacy spaced line not re-serialized by this write.
      +            assert (
      +                "TERMINAL_SSH_KEY=/Users/me/Application Support/key\n" in content
      +            )
      +            assert 'TERMINAL_SSH_KEY="' not in content
      +            assert "PLAIN=ok2\n" in content
      +
      +    def test_save_env_value_already_quoted_input_is_not_double_wrapped_idempotently(
      +        self, tmp_path
      +    ):
      +        """Callers pass raw values; if a value literally contains quote
      +        characters, escaping+wrap is the dialect (#57249). Re-saving the
      +        same raw value is stable (no quote growth). load_env round-trips.
      +        """
      +        # User-typed value that already includes surrounding quotes as data.
      +        raw = '"/Users/me/Application Support/key"'
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
      +            os.environ.pop("TERMINAL_SSH_KEY", None)
      +            save_env_value("TERMINAL_SSH_KEY", raw)
      +            first = (tmp_path / ".env").read_text(encoding="utf-8")
      +            save_env_value("TERMINAL_SSH_KEY", raw)
      +            second = (tmp_path / ".env").read_text(encoding="utf-8")
      +            assert first == second
      +            # One outer wrap layer only (escaped inner quotes, not nested wraps).
      +            line = [
      +                ln for ln in first.splitlines() if ln.startswith("TERMINAL_SSH_KEY=")
      +            ][0]
      +            assert line.startswith('TERMINAL_SSH_KEY="')
      +            assert line.endswith('"')
      +            assert line.count('TERMINAL_SSH_KEY="') == 1
      +            # Escaping dialect end-to-end: load sees the raw input, not stripped quotes.
      +            assert load_env()["TERMINAL_SSH_KEY"] == raw
      +
       
       class TestRemoveEnvValue:
           def test_removes_key_from_env_file(self, tmp_path):
      @@ -726,7 +930,7 @@ def test_atomic_write_creates_valid_yaml(self, tmp_path):
       
                   # Read raw YAML to verify it's valid and correct
                   config_path = tmp_path / "config.yaml"
      -            with open(config_path) as f:
      +            with open(config_path, encoding="utf-8") as f:
                       raw = yaml.safe_load(f)
                   assert raw["model"] == "test/atomic-model"
                   assert raw["agent"]["max_turns"] == 77
    • tests/hermes_cli/test_env_loader.py
      @@ -1,3 +1,4 @@
      +import codecs
       import importlib
       import os
       import sys
      @@ -103,3 +104,268 @@ def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch):
       
           assert os.getenv("OPENAI_BASE_URL") == "https://new.example/v1"
           assert os.getenv("HERMES_INFERENCE_PROVIDER") == "custom"
      +
      +
      +# ---------------------------------------------------------------------------
      +# UTF-16 / UTF-32 .env sanitizer coverage
      +#
      +# Scope note: intentionally NO UTF-8-BOM assertions here. UTF-8 BOM handling
      +# for _load_dotenv_with_fallback is #65124's un-merged fix; a test here would
      +# couple the PRs. This suite covers only the sanitizer rewrite path for
      +# UTF-16/32 (and UTF-8 / cp1252 regression guards for that path).
      +# ---------------------------------------------------------------------------
      +
      +
      +def _assert_clean_utf8_env_on_disk(env_file, *, first_key: str) -> None:
      +    """On-disk file must be clean UTF-8: no BOM, no U+FFFD, canonical key."""
      +    after = env_file.read_bytes()
      +    assert not after.startswith(codecs.BOM_UTF8)
      +    assert not after.startswith(codecs.BOM_UTF16_LE)
      +    assert not after.startswith(codecs.BOM_UTF16_BE)
      +    text = after.decode("utf-8")  # strict — raises if not clean UTF-8
      +    assert "\ufffd" not in text
      +    assert text.startswith(f"{first_key}=") or f"\n{first_key}=" in text
      +    assert first_key.encode("ascii") in after
      +
      +
      +def test_utf16_le_bom_env_loads_and_rewrites_clean_utf8(tmp_path, monkeypatch):
      +    """Notepad 'Unicode' (UTF-16-LE + BOM): first key loads; file rewritten UTF-8."""
      +    home = tmp_path / "hermes"
      +    home.mkdir()
      +    env_file = home / ".env"
      +    content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
      +    env_file.write_bytes(codecs.BOM_UTF16_LE + content.encode("utf-16-le"))
      +
      +    monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
      +    monkeypatch.delenv("SECOND_KEY", raising=False)
      +    monkeypatch.delenv("\ufffd\ufffdHERMES_TEST_KEY", raising=False)
      +
      +    loaded = load_hermes_dotenv(hermes_home=home)
      +
      +    assert loaded == [env_file]
      +    assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
      +    assert os.getenv("SECOND_KEY") == "world"
      +    assert os.environ.get("\ufffd\ufffdHERMES_TEST_KEY") is None
      +    _assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
      +
      +
      +def test_utf16_be_bom_env_loads_and_rewrites_clean_utf8(tmp_path, monkeypatch):
      +    """UTF-16-BE + BOM: first key loads; file rewritten as clean UTF-8."""
      +    home = tmp_path / "hermes"
      +    home.mkdir()
      +    env_file = home / ".env"
      +    content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
      +    env_file.write_bytes(codecs.BOM_UTF16_BE + content.encode("utf-16-be"))
      +
      +    monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
      +    monkeypatch.delenv("SECOND_KEY", raising=False)
      +
      +    loaded = load_hermes_dotenv(hermes_home=home)
      +
      +    assert loaded == [env_file]
      +    assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
      +    assert os.getenv("SECOND_KEY") == "world"
      +    _assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
      +
      +
      +def test_utf16_le_no_bom_still_repairs_to_utf8(tmp_path, monkeypatch):
      +    """BOM-less UTF-16-LE: NUL-strip repair is now intentional; rewrites UTF-8."""
      +    home = tmp_path / "hermes"
      +    home.mkdir()
      +    env_file = home / ".env"
      +    content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
      +    env_file.write_bytes(content.encode("utf-16-le"))  # no BOM
      +
      +    monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
      +    monkeypatch.delenv("SECOND_KEY", raising=False)
      +
      +    loaded = load_hermes_dotenv(hermes_home=home)
      +
      +    assert loaded == [env_file]
      +    assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
      +    assert os.getenv("SECOND_KEY") == "world"
      +    _assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
      +
      +
      +def test_utf16_be_no_bom_still_repairs_to_utf8(tmp_path, monkeypatch):
      +    """BOM-less UTF-16-BE: NULs are on the opposite side; still repairs."""
      +    home = tmp_path / "hermes"
      +    home.mkdir()
      +    env_file = home / ".env"
      +    content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
      +    env_file.write_bytes(content.encode("utf-16-be"))  # no BOM
      +
      +    monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
      +    monkeypatch.delenv("SECOND_KEY", raising=False)
      +
      +    loaded = load_hermes_dotenv(hermes_home=home)
      +
      +    assert loaded == [env_file]
      +    assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
      +    assert os.getenv("SECOND_KEY") == "world"
      +    _assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
      +
      +
      +def test_utf16_le_bom_preserves_non_ascii_values(tmp_path, monkeypatch):
      +    """UTF-16-LE+BOM rewrite must preserve non-ASCII values (not just ASCII keys).
      +
      +    Uses non-credential var names so _sanitize_loaded_credentials does not
      +    strip non-ASCII from values (that path only targets *_KEY/*_TOKEN/etc.).
      +    """
      +    home = tmp_path / "hermes"
      +    home.mkdir()
      +    env_file = home / ".env"
      +    content = "GREETING=café\nCJK_LABEL=日本語\n"
      +    env_file.write_bytes(codecs.BOM_UTF16_LE + content.encode("utf-16-le"))
      +
      +    monkeypatch.delenv("GREETING", raising=False)
      +    monkeypatch.delenv("CJK_LABEL", raising=False)
      +
      +    loaded = load_hermes_dotenv(hermes_home=home)
      +
      +    assert loaded == [env_file]
      +    assert os.getenv("GREETING") == "café"
      +    assert os.getenv("CJK_LABEL") == "日本語"
      +    after = env_file.read_bytes()
      +    assert after.decode("utf-8")  # strict
      +    assert "café".encode("utf-8") in after
      +    assert "日本語".encode("utf-8") in after
      +    assert b"\xef\xbf\xbd" not in after
      +
      +
      +def test_utf32_le_bom_leaves_file_untouched(tmp_path, caplog):
      +    """UTF-32-LE BOM: refuse-to-mangle (leave bytes untouched + warning).
      +
      +    UTF-32-LE's BOM starts with UTF-16-LE's FF FE; sniff order must check
      +    UTF-32 first so we never misdetect and corrupt.
      +
      +    Exercises ``_sanitize_env_file_if_needed`` only: the dotenv load path
      +    is out of scope here (#65124's surface) and still cannot ingest UTF-32.
      +    """
      +    import logging
      +
      +    from hermes_cli.env_loader import _sanitize_env_file_if_needed
      +
      +    env_file = tmp_path / ".env"
      +    content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
      +    raw = codecs.BOM_UTF32_LE + content.encode("utf-32-le")
      +    env_file.write_bytes(raw)
      +
      +    with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
      +        _sanitize_env_file_if_needed(env_file)
      +
      +    assert env_file.read_bytes() == raw  # untouched
      +    assert any("UTF-32" in r.message for r in caplog.records)
      +
      +
      +def test_utf32_be_bom_leaves_file_untouched(tmp_path, caplog):
      +    """UTF-32-BE BOM: same refuse-to-mangle path as LE (ordering independence)."""
      +    import logging
      +
      +    from hermes_cli.env_loader import _sanitize_env_file_if_needed
      +
      +    env_file = tmp_path / ".env"
      +    content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
      +    raw = codecs.BOM_UTF32_BE + content.encode("utf-32-be")
      +    env_file.write_bytes(raw)
      +
      +    with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
      +        _sanitize_env_file_if_needed(env_file)
      +
      +    assert env_file.read_bytes() == raw
      +    assert any("UTF-32" in r.message for r in caplog.records)
      +
      +
      +def test_utf32_warning_fires_once_per_path(tmp_path, caplog, monkeypatch):
      +    """Three sanitize calls on the same UTF-32 file → exactly one warning.
      +
      +    Matches house style for warn-once (module-level seen-set, same class as
      +    ``_WARNED_KEYS``): hot-reload / multi-entry load must not spam logs.
      +    """
      +    import logging
      +
      +    import hermes_cli.env_loader as env_loader
      +    from hermes_cli.env_loader import _sanitize_env_file_if_needed
      +
      +    # Isolate process-level seen-set so other tests' paths don't leak in.
      +    monkeypatch.setattr(env_loader, "_WARNED_UTF32_PATHS", set())
      +
      +    env_file = tmp_path / ".env"
      +    content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
      +    raw = codecs.BOM_UTF32_LE + content.encode("utf-32-le")
      +    env_file.write_bytes(raw)
      +
      +    with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
      +        _sanitize_env_file_if_needed(env_file)
      +        _sanitize_env_file_if_needed(env_file)
      +        _sanitize_env_file_if_needed(env_file)
      +
      +    utf32_warnings = [r for r in caplog.records if "UTF-32" in r.message]
      +    assert len(utf32_warnings) == 1
      +    assert env_file.read_bytes() == raw
      +
      +
      +def test_leading_replacement_char_does_not_rewrite(tmp_path):
      +    """errors=replace FFFD-on-first-line guard: do not persist mangling.
      +
      +    Leading 0xFF is not a UTF-16/32 BOM (those need the second BOM byte) but
      +    is undecodable as UTF-8, so the replace path would glue U+FFFD onto the
      +    key. The guard must leave the on-disk bytes untouched.
      +    """
      +    from hermes_cli.env_loader import _sanitize_env_file_if_needed
      +
      +    env_file = tmp_path / ".env"
      +    raw = b"\xffHERMES_TEST_KEY=should-not-rewrite\nSECOND_KEY=ok\n"
      +    env_file.write_bytes(raw)
      +
      +    _sanitize_env_file_if_needed(env_file)
      +
      +    assert env_file.read_bytes() == raw
      +
      +
      +def test_plain_utf8_env_regression(tmp_path, monkeypatch):
      +    """Plain UTF-8 .env must keep loading after the UTF-16 sanitize changes."""
      +    home = tmp_path / "hermes"
      +    home.mkdir()
      +    env_file = home / ".env"
      +    before = b"OPENAI_API_KEY=[redacted:secret-assignment]"
      +    env_file.write_bytes(before)
      +
      +    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
      +    monkeypatch.delenv("SECOND_KEY", raising=False)
      +
      +    loaded = load_hermes_dotenv(hermes_home=home)
      +
      +    assert loaded == [env_file]
      +    assert os.getenv("OPENAI_API_KEY") == "sk-plain"
      +    assert os.getenv("SECOND_KEY") == "ok"
      +    # No spurious rewrite of an already-clean file.
      +    assert env_file.read_bytes() == before
      +
      +
      +def test_cp1252_env_regression_does_not_crash(tmp_path, monkeypatch):
      +    """cp1252/latin-1 body must not crash sanitize; ASCII keys still usable.
      +
      +    0xE9 is 'é' in cp1252 and incomplete as UTF-8. First line does not begin
      +    with U+FFFD, so the FFFD guard must not refuse the whole file.
      +
      +    Sanitize leaves the file bytes alone when the only "change" is
      +    errors=replace on values (original already replace-decoded equals
      +    sanitized), so _load_dotenv_with_fallback's latin-1 path recovers café.
      +    """
      +    home = tmp_path / "hermes"
      +    home.mkdir()
      +    env_file = home / ".env"
      +    before = b"ASCII_KEY=ok\nLATIN1_VALUE=caf\xe9\n"
      +    env_file.write_bytes(before)
      +
      +    monkeypatch.delenv("ASCII_KEY", raising=False)
      +    monkeypatch.delenv("LATIN1_VALUE", raising=False)
      +
      +    loaded = load_hermes_dotenv(hermes_home=home)
      +
      +    assert loaded == [env_file]
      +    assert os.getenv("ASCII_KEY") == "ok"
      +    assert os.getenv("LATIN1_VALUE") == "café"
      +    # Sanitize must not have rewritten (would have persisted U+FFFD).
      +    assert env_file.read_bytes() == before
  • #78682 fix(install): Windows install completes on profiles with a space, dot, or accent in the username

    merged to main

    files: .github/actions/detect-changes/action.yml, .github/workflows/ci.yml, .github/workflows/installer-tests.yml, scripts/ci/classify_changes.py, scripts/install.ps1, scripts/tests/test-install-ps1-longpath.ps1, tests/ci/test_classify_changes.py

  • #102765 bundles & unified package manager

    merged to main

    files: .dockerignore, .gitattributes, .github/actionlint.yaml, .github/actions/desktop-build-cache/action.yml, .github/actions/detect-changes/action.yml, .github/actions/e2e-screen-record/action.yml, .github/actions/plugin-validate/action.yml, .github/actions/save-pm-cache/README.md, .github/actions/save-pm-cache/action.yml, .github/actions/setup-pm/README.md, .github/actions/setup-pm/action.yml, .github/actions/setup-pm/prune/action.yml, .github/actions/setup-pm/prune/index.mjs, .github/actions/setup-windows-build-deps/action.yml, .github/workflows/archive-inputs.yml, .github/workflows/bootstrap-installer.yml, .github/workflows/canary-release.yml, .github/workflows/ci.yaml, .github/workflows/contributor-check.yml, .github/workflows/deploy-site.yml (+2978 more)

  • #113919 fix(desktop): Windows install no longer fails on non-ASCII profile paths (#70779, salvage #66687)

    merged to main

    files: apps/desktop/scripts/before-pack.mjs, apps/desktop/scripts/stage-native-deps.mjs, apps/desktop/scripts/stage-native-deps.test.mjs

15 PRs closed without merging

From the close comments: done elsewhere 3 · superseded by another PR 3 · duplicate 2 · merged via another change 2 · closed without comment 1 · partial fix 1 · stale or abandoned 1 · other 2

  • #3021 fix(install): quote PYTHON_PATH to handle paths with spaces on macOS · duplicate → #8349
  • #4680 Fix install.sh silently aborting on macOS when uv Python path contains spaces · done elsewhere → 00192d5, #10009
  • #10010 fix(install): quote PYTHON_PATH in version checks to handle spaces on macOS · superseded by another PR → #13001
  • #10018 fix: quote $PYTHON_PATH and $UV_CMD in install.sh for paths with spaces · stale or abandoned
  • #10019 fix(install): quote python path variables to handle spaces · merged via another change → #13001
Show 10 more
  • #39420 fix(install): expand 8.3 short %TEMP% so Windows Node/Electron stages don't abort · merged via another change → e74033b, ac83365, #49838
  • #40923 fix(install): quote managed tool paths so install works under $HOME with spaces · superseded by another PR → 1b91b8e, 34efc67, #102765, #40820
  • #47892 fix(install): reject HERMES_HOME paths with spaces early (#47822) · other
  • #54142 fix(install): normalize 8.3 short profile paths so Windows desktop stage doesn't abort (#43334) · closed without comment
  • #60480 fix(desktop): replace cpSync with manual recursive copy for Windows non-ASCII path compatibility · partial fix → 97d9828
  • #61833 fix(install): normalize accented-username 8.3 short paths (GH #52842) · superseded by another PR → #78682, #57526
  • #66483 fix(cli): quote .env values with internal whitespace in save_env_value · other → #67192
  • #66583 fix(save_env_value): quote values with internal spaces to prevent shell-sourcing breakage (#66482) · done elsewhere → #67192, #66482, #66483
  • #66625 fix(config): quote .env values containing internal whitespace (#66482) · done elsewhere → #67192, #66482, #66483
  • #66711 fix(config): quote .env values with internal whitespace (#66482) · duplicate
Closed issues · 12
  • Closed#3000[Bug]: install.sh silently aborts on macOS when uv-managed Python path contains spaces
  • Closed#10009[Bug]: install.sh fails on macOS due to unquoted spaces in Python path (uv)
  • Closed#38163[Bug]:Desktop app installer fails on macOS when home directory path contains a space
  • Closed#38773Desktop installer fails on Windows with dot in username (8.3 path resolution bug in hermes_bootstrap_lib)
  • Closed#39308[Bug]: Windows desktop installer fails at Node/Electron stages — 8.3 short name not resolved for profile path with space
  • Closed#40820[Bug]: Desktop installer fails on macOS when home directory path contains spaces (external drive)
  • Closed#43334[Feature]: Hermes Desktop Installer, please add Navigation to custum installation folder
  • Closed#52842[Bug]: Installer fails repeatedly with "No existe ningún objeto en la ruta..." (8.3 short-path resolution) when Windows username contains accented characters
  • Closed#57247Installer writes unquoted AGENT_BROWSER_EXECUTABLE_PATH override into .env — spaced paths break shell source
  • Closed#60447stage-native-deps.cjs cpSync fails on Windows when user path contains non-ASCII characters
  • Closed#60740[Bug]: Installation fails on Windows with non-ASCII (Cyrillic) usernames - Path resolution error "C:\Users\3EC2~1 does not exist"
  • Closed#66482save_env_value writes unquoted values with internal spaces - breaks shell-sourcing of .env (core-writer sibling of #57247)
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.05: 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
#13001, #49838, #67192, #78682, #102765, #113919
Closed issues
12
Attempts
15 PRs closed without merging
Fix link checked
24 Sep 2026

For your agent

The same facts, typed.