diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index a7f4b983dcbf..9a2077200021 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -302,6 +302,9 @@ def _build_gateway_cmd_script( The script: - cd's into the project directory - exports HERMES_HOME, PYTHONIOENCODING, VIRTUAL_ENV + - on uv-managed venvs (per ``_resolve_detached_python``), also exports + PYTHONPATH so the base ``pythonw.exe`` can import the venv's + ``site-packages`` - invokes ``pythonw -m hermes_cli.main [--profile X] gateway run`` directly so the wrapper cmd.exe exits without a visible gateway console @@ -314,12 +317,21 @@ def _build_gateway_cmd_script( lines.append(f'set "HERMES_HOME={hermes_home}"') lines.append('set "PYTHONIOENCODING=utf-8"') lines.append('set "HERMES_GATEWAY_DETACHED=1"') - # VIRTUAL_ENV lets the gateway's own python detection find the venv - # if someone imports hermes_constants-based logic during startup. - venv_dir = str(Path(python_path).resolve().parent.parent) - lines.append(f'set "VIRTUAL_ENV={venv_dir}"') + # Mirror _build_gateway_argv: uv-managed venvs ship a shim pythonw.exe + # that respawns the base console python.exe, which surfaces as a visible + # cmd.exe window when launched from the Scheduled Task. Resolve to the + # base interpreter and inject site-packages on PYTHONPATH the same way + # the direct-spawn path does. + pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path) + lines.append(f'set "VIRTUAL_ENV={venv_dir.resolve()}"') + if extra_pythonpath: + # The emitted ``.cmd`` is consumed by cmd.exe on Windows, so use the + # Windows path separator literally rather than ``os.pathsep`` — the + # latter would yield ``:`` if this script were ever generated off + # Windows (e.g. cross-platform build tooling or unit tests). + pythonpath_value = ";".join([working_dir, *extra_pythonpath]) + lines.append(f'set "PYTHONPATH={pythonpath_value}"') - pythonw_path = _derive_venv_pythonw(python_path) prog_args = [pythonw_path, "-m", "hermes_cli.main"] if profile_arg: prog_args.extend(profile_arg.split()) diff --git a/tests/hermes_cli/test_gateway_windows.py b/tests/hermes_cli/test_gateway_windows.py index e61302198282..a4d22513fcd1 100644 --- a/tests/hermes_cli/test_gateway_windows.py +++ b/tests/hermes_cli/test_gateway_windows.py @@ -67,6 +67,56 @@ def test_build_gateway_argv_uses_base_pythonw_for_uv_venv_launcher(monkeypatch, assert str(site_packages) in env_overlay["PYTHONPATH"].split(gateway_windows.os.pathsep) +def test_build_gateway_cmd_script_uses_base_pythonw_for_uv_venv_launcher(monkeypatch, tmp_path): + """Scheduled-Task .cmd wrapper must mirror _build_gateway_argv on uv venvs. + + The venv/Scripts/pythonw.exe shipped by uv is a launcher shim that + respawns the base console python.exe, which renders as a visible cmd.exe + window when started by the Scheduled Task. The .cmd generator must + resolve to the base pythonw.exe and inject the venv site-packages on + PYTHONPATH the same way the direct-spawn path does. + """ + project = tmp_path / "project" + scripts = project / "venv" / "Scripts" + site_packages = project / "venv" / "Lib" / "site-packages" + base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none" + scripts.mkdir(parents=True) + site_packages.mkdir(parents=True) + base.mkdir(parents=True) + + venv_python = scripts / "python.exe" + venv_pythonw = scripts / "pythonw.exe" + base_pythonw = base / "pythonw.exe" + for exe in (venv_python, venv_pythonw, base_pythonw): + exe.write_text("", encoding="utf-8") + (project / "venv" / "pyvenv.cfg").write_text( + f"home = {base}\nimplementation = CPython\nuv = 0.11.14\nversion_info = 3.11.15\n", + encoding="utf-8", + ) + + content = gateway_windows._build_gateway_cmd_script( + str(venv_python), + str(project), + str(tmp_path / "hermes-home"), + "--profile alice", + ) + + assert str(base_pythonw) in content + assert str(venv_pythonw) not in content + assert f'set "VIRTUAL_ENV={(project / "venv").resolve()}"' in content + pythonpath_line = next( + line for line in content.splitlines() if line.startswith('set "PYTHONPATH=') + ) + pythonpath_value = pythonpath_line.split("=", 1)[1].rstrip('"') + # The generated script is Windows-targeted, so its PYTHONPATH always + # uses ``;`` as the separator regardless of the host OS running this + # test. Use the literal Windows separator instead of os.pathsep. + pythonpath_entries = pythonpath_value.split(";") + assert str(project) in pythonpath_entries + assert str(site_packages) in pythonpath_entries + assert "gateway run" in content + + def _arrange_startup_fallback(monkeypatch, tmp_path, running_pids): script_path = tmp_path / "Hermes_Gateway_alice.cmd" startup_entry = tmp_path / "Startup" / "Hermes_Gateway_alice.cmd"