diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd8ee37..d197cb6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.236] — 2026-06-03 — Release HD (stage-q7 — native Windows support for bootstrap and terminal) + +### Added +- Native Windows support for `bootstrap.py` and the embedded terminal (#1952). Hermes WebUI already ran on Windows when invoked as `python server.py` directly; this unblocks the supported `python bootstrap.py` path. `api/terminal.py` no longer hard-imports the POSIX-only `fcntl`/`termios`/`select` at module load — they're guarded behind `_TERMINAL_SUPPORTED = sys.platform != "win32"`, and the embedded-terminal entry points raise `NotImplementedError` (or no-op) on Windows, following the existing optional-feature guard pattern (`api/turn_journal.py`, `api/providers.py`). The bootstrap native-Windows block becomes a warning instead of a hard `RuntimeError`; auto-install (which shells out to `/bin/bash`) still errors clearly on native Windows (WSL is unaffected), and the foreground launch path uses `subprocess.Popen` + exit on Windows (where `os.execv` spawns rather than replaces the process, orphaning it from a supervisor) instead of `os.execv`. POSIX behavior is unchanged on every path (#1952, @rodboev). + ## [v0.51.235] — 2026-06-03 — Release HC (stage-q5 — no duplicate transcript replay on repeated questions after compression) ### Fixed diff --git a/api/terminal.py b/api/terminal.py index 5793d91c..c85e1f3e 100644 --- a/api/terminal.py +++ b/api/terminal.py @@ -11,20 +11,29 @@ from __future__ import annotations import errno import atexit import codecs -import fcntl import os import queue -import select import shutil import signal import struct import subprocess -import termios +import sys import threading import time from dataclasses import dataclass, field from pathlib import Path +_TERMINAL_SUPPORTED = sys.platform != "win32" + +if _TERMINAL_SUPPORTED: + import fcntl + import select + import termios +else: + fcntl = None # type: ignore[assignment] + select = None # type: ignore[assignment] + termios = None # type: ignore[assignment] + def _set_nonblocking(fd: int) -> None: flags = fcntl.fcntl(fd, fcntl.F_GETFL) @@ -175,7 +184,8 @@ def _ensure_spawn_supervisor() -> None: _spawn_supervisor_started = True -_ensure_spawn_supervisor() +if _TERMINAL_SUPPORTED: + _ensure_spawn_supervisor() # NOTE on parent-death-signal: a previous version of this module set @@ -257,6 +267,8 @@ def _set_size(term: TerminalSession, rows: int, cols: int) -> None: def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = 80, restart: bool = False) -> TerminalSession: """Start or return the embedded terminal for a WebUI session.""" + if not _TERMINAL_SUPPORTED: + raise NotImplementedError("Embedded terminal is not supported on Windows") sid = str(session_id or "").strip() if not sid: raise ValueError("session_id is required") @@ -346,6 +358,8 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = def get_terminal(session_id: str) -> TerminalSession | None: + if not _TERMINAL_SUPPORTED: + return None with _LOCK: term = _TERMINALS.get(str(session_id or "")) if term and term.is_alive(): @@ -354,6 +368,8 @@ def get_terminal(session_id: str) -> TerminalSession | None: def write_terminal(session_id: str, data: str) -> None: + if not _TERMINAL_SUPPORTED: + raise NotImplementedError("Embedded terminal is not supported on Windows") term = get_terminal(session_id) if not term or not term.is_alive(): raise KeyError("terminal not running") @@ -361,6 +377,8 @@ def write_terminal(session_id: str, data: str) -> None: def resize_terminal(session_id: str, rows: int, cols: int) -> None: + if not _TERMINAL_SUPPORTED: + raise NotImplementedError("Embedded terminal is not supported on Windows") term = get_terminal(session_id) if not term: raise KeyError("terminal not running") @@ -368,6 +386,8 @@ def resize_terminal(session_id: str, rows: int, cols: int) -> None: def close_terminal(session_id: str) -> bool: + if not _TERMINAL_SUPPORTED: + return False sid = str(session_id or "") with _LOCK: term = _TERMINALS.pop(sid, None) diff --git a/bootstrap.py b/bootstrap.py index a1b7ea62..d8e688d7 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -92,9 +92,9 @@ def is_wsl() -> bool: def ensure_supported_platform() -> None: if platform.system() == "Windows" and not is_wsl(): - raise RuntimeError( - "Native Windows is not supported for this bootstrap yet. " - "Please run it from Linux, macOS, or inside WSL2." + info( + "Warning: Native Windows bootstrap is experimental. " + "Embedded terminal and auto-install are not supported." ) @@ -270,6 +270,11 @@ def hermes_command_exists() -> bool: def install_hermes_agent() -> None: + if platform.system() == "Windows" and not is_wsl(): + raise RuntimeError( + "Auto-install is not supported on native Windows. " + "Install hermes-agent manually first." + ) info(f"Hermes Agent not found. Attempting install via {INSTALLER_URL}") subprocess.run( ["/bin/bash", "-lc", f"curl -fsSL {INSTALLER_URL} | bash"], check=True @@ -316,8 +321,10 @@ def parse_args() -> argparse.Namespace: "--foreground", action="store_true", help=( - "Run server.py in this process (via os.execv) instead of spawning a " - "child. Use this under launchd / systemd / supervisord so the " + "Run server.py in this process (via os.execv on POSIX; via a " + "Popen child + exit on Windows, where execv can't replace the " + "process image) instead of spawning a detached child. Use this " + "under launchd / systemd / supervisord so the " "supervisor sees the long-lived server as the original child. " "Implies --no-browser. Skips the post-launch health probe — the " "supervisor's own KeepAlive / Restart=on-failure handles liveness." @@ -444,8 +451,19 @@ def main() -> int: f"Set HERMES_WEBUI_PYTHON to a working interpreter or fix " f"the agent venv at {agent_dir}." ) - # os.execv replaces the current process image. Anything after this line - # only runs if execv itself fails (it raises OSError on failure). + # os.execv replaces the current process image. On Windows, execv + # spawns a new process instead of replacing (Python calls CreateProcess), + # orphaning it from any supervisor. Use Popen + exit there instead. + if sys.platform == "win32": + # CREATE_NEW_PROCESS_GROUP only exists in the subprocess module on + # Windows; resolve it defensively (0 = no extra flags) so this line + # can't AttributeError if reached on a non-Windows interpreter + # (e.g. a win32-simulating test) — mirrors the getattr() guard used + # for SO_EXCLUSIVEADDRUSE. + _CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + subprocess.Popen([python_exe, server_path], + creationflags=_CREATE_NEW_PROCESS_GROUP) + sys.exit(0) os.execv(python_exe, [python_exe, server_path]) # Unreachable — execv either replaces the process or raises. raise RuntimeError("os.execv returned unexpectedly") diff --git a/tests/test_onboarding_static.py b/tests/test_onboarding_static.py index b0d7bb95..52196eaa 100644 --- a/tests/test_onboarding_static.py +++ b/tests/test_onboarding_static.py @@ -55,4 +55,8 @@ def test_bootstrap_script_contains_official_installer_and_windows_guard(): "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh" in src ) - assert "Native Windows is not supported" in src + # Native Windows is now experimental-supported (#1952), not hard-blocked: + # ensure_supported_platform() warns instead of raising, but auto-install + # (which shells out to /bin/bash) still guards native Windows explicitly. + assert "Native Windows bootstrap is experimental" in src + assert "Auto-install is not supported on native Windows" in src diff --git a/tests/test_windows_native_support.py b/tests/test_windows_native_support.py new file mode 100644 index 00000000..e925caa7 --- /dev/null +++ b/tests/test_windows_native_support.py @@ -0,0 +1,214 @@ +"""Tests for native Windows support (terminal.py POSIX guards + bootstrap.py unblock). + +terminal.py guards: +- _TERMINAL_SUPPORTED is False on Windows, True on POSIX +- Terminal functions raise NotImplementedError on Windows +- close_terminal returns False (no-op) on Windows +- get_terminal returns None on Windows +- Module imports cleanly on Windows (no fcntl/termios ImportError) + +bootstrap.py unblock: +- ensure_supported_platform does not raise on Windows +- install_hermes_agent raises RuntimeError on Windows (calls /bin/bash) +- Foreground path uses Popen + sys.exit on Windows instead of os.execv +""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(REPO_ROOT)) + + +# ── terminal.py tests ──────────────────────────────────────────────────────── + + +class TestTerminalPosixGuard: + """Verify _TERMINAL_SUPPORTED flag and import guards.""" + + def test_terminal_supported_matches_platform(self): + from api import terminal + if sys.platform == "win32": + assert not terminal._TERMINAL_SUPPORTED + else: + assert terminal._TERMINAL_SUPPORTED + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only check") + def test_posix_modules_are_loaded(self): + from api import terminal + assert terminal.fcntl is not None + assert terminal.select is not None + assert terminal.termios is not None + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-only check") + def test_windows_modules_are_none(self): + from api import terminal + assert terminal.fcntl is None + assert terminal.select is None + assert terminal.termios is None + + +class TestTerminalWindowsFunctions: + """Terminal functions should fail gracefully on Windows.""" + + @pytest.fixture(autouse=True) + def _force_unsupported(self, monkeypatch): + """Force _TERMINAL_SUPPORTED=False regardless of actual platform.""" + from api import terminal + monkeypatch.setattr(terminal, "_TERMINAL_SUPPORTED", False) + + def test_start_terminal_raises(self, tmp_path): + from api.terminal import start_terminal + with pytest.raises(NotImplementedError, match="not supported on Windows"): + start_terminal("test-session", tmp_path) + + def test_write_terminal_raises(self): + from api.terminal import write_terminal + with pytest.raises(NotImplementedError, match="not supported on Windows"): + write_terminal("test-session", "hello") + + def test_resize_terminal_raises(self): + from api.terminal import resize_terminal + with pytest.raises(NotImplementedError, match="not supported on Windows"): + resize_terminal("test-session", 24, 80) + + def test_close_terminal_returns_false(self): + from api.terminal import close_terminal + assert close_terminal("test-session") is False + + def test_get_terminal_returns_none(self): + from api.terminal import get_terminal + assert get_terminal("test-session") is None + + +# ── bootstrap.py tests ─────────────────────────────────────────────────────── + + +@pytest.fixture +def import_bootstrap(): + """Import bootstrap freshly to avoid module-level state bleed.""" + if "bootstrap" in sys.modules: + del sys.modules["bootstrap"] + import bootstrap as bs + return bs + + +class TestBootstrapWindowsUnblock: + + def test_ensure_supported_platform_does_not_raise_on_windows(self, import_bootstrap, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setattr(import_bootstrap, "is_wsl", lambda: False) + # Should print a warning, not raise + import_bootstrap.ensure_supported_platform() + + def test_ensure_supported_platform_still_passes_on_posix(self, import_bootstrap, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + import_bootstrap.ensure_supported_platform() + + def test_install_hermes_agent_raises_on_windows(self, import_bootstrap, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setattr(import_bootstrap, "is_wsl", lambda: False) + with pytest.raises(RuntimeError, match="not supported on native Windows"): + import_bootstrap.install_hermes_agent() + + def test_install_hermes_agent_allowed_in_wsl(self, import_bootstrap, monkeypatch): + """WSL should still be able to auto-install (it has /bin/bash) — the + Windows guard must NOT fire. Stub subprocess.run so the test proves the + guard was passed without actually launching the real installer.""" + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setattr(import_bootstrap, "is_wsl", lambda: True) + run_calls = [] + monkeypatch.setattr( + import_bootstrap.subprocess, "run", + lambda *a, **kw: run_calls.append((a, kw)), + ) + # Must NOT raise the native-Windows guard; instead it reaches the + # (stubbed) install subprocess. + import_bootstrap.install_hermes_agent() + assert len(run_calls) == 1, "WSL install should reach the install subprocess" + + +class TestBootstrapForegroundWindows: + """Foreground path uses Popen + sys.exit on Windows instead of os.execv.""" + + @pytest.fixture + def stub_main_dependencies(self, monkeypatch, tmp_path): + import bootstrap as bs + monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None) + monkeypatch.setattr(bs, "discover_agent_dir", lambda: tmp_path / "agent") + monkeypatch.setattr(bs, "hermes_command_exists", lambda: True) + python_exe = sys.executable + monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: python_exe) + monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0]) + monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True) + monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None) + monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(tmp_path / "state")) + (tmp_path / "agent").mkdir(parents=True, exist_ok=True) + return bs + + def test_foreground_uses_popen_on_windows(self, stub_main_dependencies, monkeypatch): + """On win32, foreground mode should use Popen + sys.exit, not os.execv.""" + bs = stub_main_dependencies + monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"]) + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(os, "chdir", lambda p: None) + + # Strip supervisor env vars that would interfere + for var in ("INVOCATION_ID", "JOURNAL_STREAM", "NOTIFY_SOCKET", + "XPC_SERVICE_NAME", "SUPERVISOR_ENABLED", "HERMES_WEBUI_FOREGROUND"): + monkeypatch.delenv(var, raising=False) + + popen_calls = [] + execv_calls = [] + + class FakePopen: + pid = 12345 + def __init__(self, *args, **kwargs): + popen_calls.append((args, kwargs)) + + monkeypatch.setattr(subprocess, "Popen", FakePopen) + monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a)) + + with pytest.raises(SystemExit) as ei: + bs.main() + assert ei.value.code == 0 + assert len(popen_calls) == 1, "Windows foreground should use Popen" + assert len(execv_calls) == 0, "Windows foreground must NOT use execv" + + def test_foreground_uses_execv_on_posix(self, stub_main_dependencies, monkeypatch): + """On POSIX, foreground mode should still use os.execv.""" + bs = stub_main_dependencies + monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"]) + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "chdir", lambda p: None) + + for var in ("INVOCATION_ID", "JOURNAL_STREAM", "NOTIFY_SOCKET", + "XPC_SERVICE_NAME", "SUPERVISOR_ENABLED", "HERMES_WEBUI_FOREGROUND"): + monkeypatch.delenv(var, raising=False) + + execv_calls = [] + popen_calls = [] + + def fake_execv(path, argv): + execv_calls.append((path, argv)) + raise SystemExit(0) + + monkeypatch.setattr(os, "execv", fake_execv) + + class FakePopen: + pid = 12345 + def __init__(self, *args, **kwargs): + popen_calls.append((args, kwargs)) + + monkeypatch.setattr(subprocess, "Popen", FakePopen) + + with pytest.raises(SystemExit) as ei: + bs.main() + assert ei.value.code == 0 + assert len(execv_calls) == 1, "POSIX foreground should use execv" + assert len(popen_calls) == 0, "POSIX foreground must NOT use Popen"