diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 65df4cc5d93..30c475d6f97 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4443,14 +4443,17 @@ def _clear_bytecode_cache(root: Path) -> int: return removed -# Critical files that every ``hermes`` invocation imports at startup. If any -# of these fail to parse after a pull, the CLI is bricked — the user can't -# even run ``hermes update`` again to roll forward. The post-pull syntax -# guard validates these and auto-rolls-back on failure. +# Critical files that Hermes must be able to import immediately after an +# update/install. Most are imported on every CLI startup; ``web_server.py`` +# is the desktop/dashboard backend path that a fresh Windows install launches +# right away. If any of these fail to parse after a pull, the user can be +# left with a bricked CLI or desktop backend. The post-pull syntax guard +# validates these and auto-rolls-back on failure. _UPDATE_CRITICAL_FILES = ( "hermes_cli/main.py", "hermes_cli/config.py", "hermes_cli/__init__.py", + "hermes_cli/web_server.py", "cli.py", "run_agent.py", "model_tools.py", diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 19df6d3131b..179bca54b74 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1995,6 +1995,7 @@ print(','.join(scripts)) $pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) } if (Test-Path $pythonExe) { $webOk = $false + $webServerSyntaxOk = $false # Relax EAP=Stop while running the import probe; see the matching # comment on the baseline-imports check above. Python writes # deprecation warnings to stderr and we don't want those wrapped @@ -2006,6 +2007,10 @@ print(','.join(scripts)) & $pythonExe -c "import fastapi, uvicorn" 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { $webOk = $true } } catch { } + try { + & $pythonExe -m py_compile "$InstallDir\hermes_cli\web_server.py" 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { $webServerSyntaxOk = $true } + } catch { } $ErrorActionPreference = $prevEAP if (-not $webOk) { Write-Warn "fastapi/uvicorn not importable -- `hermes dashboard` will not work." @@ -2017,6 +2022,9 @@ print(','.join(scripts)) Write-Warn "Could not install [web] extra. Run manually: uv pip install --python `"$pythonExe`" `"fastapi>=0.104,<1`" `"uvicorn[standard]>=0.24,<1`"" } } + if (-not $webServerSyntaxOk) { + throw "dashboard backend source failed syntax check: hermes_cli/web_server.py" + } } Pop-Location diff --git a/tests/hermes_cli/test_update_post_pull_syntax_guard.py b/tests/hermes_cli/test_update_post_pull_syntax_guard.py index 805ac1c0f02..e34b80a6bfb 100644 --- a/tests/hermes_cli/test_update_post_pull_syntax_guard.py +++ b/tests/hermes_cli/test_update_post_pull_syntax_guard.py @@ -116,6 +116,15 @@ def test_validate_critical_files_syntax_detects_break_in_main_py(tmp_path): assert failing_path is not None and failing_path.endswith("hermes_cli/main.py") +def test_validate_critical_files_syntax_detects_break_in_web_server(tmp_path): + _populate_critical_tree(tmp_path, broken_file="hermes_cli/web_server.py") + + ok, failing_path, _ = hermes_main._validate_critical_files_syntax(tmp_path) + + assert ok is False + assert failing_path is not None and failing_path.endswith("hermes_cli/web_server.py") + + def test_validate_critical_files_syntax_tolerates_missing_files(tmp_path): """A refactor may legitimately remove one of the critical files — the guard should skip missing files, not falsely flag the install as broken.""" diff --git a/tests/test_install_ps1_web_server_syntax_probe.py b/tests/test_install_ps1_web_server_syntax_probe.py new file mode 100644 index 00000000000..14f99f725c8 --- /dev/null +++ b/tests/test_install_ps1_web_server_syntax_probe.py @@ -0,0 +1,49 @@ +"""Regression: install.ps1 must syntax-check the dashboard backend source. + +Issue #59004 reported a fresh Windows desktop install crashing on launch +because ``hermes_cli/web_server.py`` inside the installed checkout still +contained merge-conflict markers. Import-only dependency probes (fastapi / +uvicorn) do not catch that: the packages can be present while the backend +source itself is unparsable. + +This test is source-level because Linux CI cannot execute the PowerShell +installer. It locks the contract that install.ps1 runs ``py_compile`` against +``hermes_cli/web_server.py`` and fails the stage when that syntax probe fails. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" + + +def test_install_ps1_compiles_web_server_source_after_web_deps_probe() -> None: + text = INSTALL_PS1.read_text(encoding="utf-8") + + probe = re.search( + r'import fastapi, uvicorn[\s\S]{0,1200}?-m py_compile "\$InstallDir\\hermes_cli\\web_server\.py"', + text, + ) + assert probe is not None, ( + "install.ps1 must syntax-check hermes_cli/web_server.py after the " + "dashboard dependency probe so a fresh desktop install fails early on " + "merge-conflict markers or other SyntaxErrors." + ) + + +def test_install_ps1_fails_stage_when_web_server_syntax_probe_fails() -> None: + text = INSTALL_PS1.read_text(encoding="utf-8") + + assert "if ($LASTEXITCODE -eq 0) { $webServerSyntaxOk = $true }" in text + assert "if (-not $webServerSyntaxOk) {" in text + assert ( + 'throw "dashboard backend source failed syntax check: hermes_cli/web_server.py"' + in text + ), ( + "install.ps1 must fail the install stage when hermes_cli/web_server.py " + "does not compile, instead of writing a broken desktop/backend install." + )