Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -797,12 +797,15 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:
# See gateway_windows.windowless_gateway_restart_spec.
respawn_cwd = ""
respawn_env_overlay: dict[str, str] = {}
watcher_env: dict[str, str] | None = None
if sys.platform == "win32":
try:
from hermes_cli.gateway_windows import (
root_gateway_subprocess_env,
windowless_gateway_restart_spec,
)

watcher_env = root_gateway_subprocess_env(os.environ)
run_argv, respawn_cwd, respawn_env_overlay = (
windowless_gateway_restart_spec(list(run_argv))
)
Expand Down Expand Up @@ -892,12 +895,15 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:

# Same platform-aware detach for the watcher process itself — so
# closing the user's terminal doesn't kill the watcher.
watcher_popen_kwargs: dict = windows_detach_popen_kwargs()
if watcher_env is not None:
watcher_popen_kwargs["env"] = watcher_env
try:
subprocess.Popen(
watcher_argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**windows_detach_popen_kwargs(),
**watcher_popen_kwargs,
)
except OSError:
# CREATE_BREAKAWAY_FROM_JOB rejected by the parent job object
Expand All @@ -911,6 +917,8 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:
if sys.platform == "win32"
else {"start_new_session": True}
)
if watcher_env is not None:
fallback_kwargs["env"] = watcher_env
subprocess.Popen(
watcher_argv,
stdout=subprocess.DEVNULL,
Expand Down
33 changes: 31 additions & 2 deletions hermes_cli/gateway_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@
import subprocess
import sys
import time
from collections.abc import Mapping
from pathlib import Path
from xml.sax.saxutils import escape

from agent.delegation_context import DELEGATED_CHILD_ENV_MARKER, KANBAN_ENV_KEYS
from hermes_cli._subprocess_compat import (
windows_detach_flags,
windows_detach_flags_without_breakaway,
Expand All @@ -61,6 +63,27 @@
_TASK_RESTART_INTERVAL = "PT1M"
_TASK_RESTART_COUNT = 999

_ROOT_GATEWAY_CHILD_SCOPE_ENV_KEYS = (
DELEGATED_CHILD_ENV_MARKER,
*KANBAN_ENV_KEYS,
# Persisted launchers can outlive versions that used these legacy keys.
"HERMES_KANBAN_BRANCH",
"HERMES_KANBAN_WORKTREE",
)


def root_gateway_subprocess_env(
base: Mapping[str, str],
overlay: Mapping[str, str] | None = None,
) -> dict[str, str]:
"""Build a root-gateway environment without delegated-worker ownership."""
env = dict(base)
if overlay:
env.update(overlay)
for key in _ROOT_GATEWAY_CHILD_SCOPE_ENV_KEYS:
env.pop(key, None)
return env


def _schtasks_encoding() -> str:
"""Best-effort console encoding for decoding ``schtasks.exe`` output.
Expand Down Expand Up @@ -420,6 +443,7 @@ def _build_gateway_cmd_script(
*[_preserve_hermes_home_path(entry) for entry in extra_pythonpath],
]
lines.append(f'set "PYTHONPATH={";".join([*pythonpath_entries, "%PYTHONPATH%"])}"')
lines.extend(f'set "{key}="' for key in _ROOT_GATEWAY_CHILD_SCOPE_ENV_KEYS)

prog_args = [python_exe_path, "-m", "hermes_cli.main"]
if profile_arg:
Expand Down Expand Up @@ -505,6 +529,10 @@ def _build_gateway_vbs_script(
"Else",
f" env.Item({_quote_vbs_string('PYTHONPATH')}) = {_quote_vbs_string(static_pythonpath)}",
"End If",
*[
f"env.Remove {_quote_vbs_string(key)}"
for key in _ROOT_GATEWAY_CHILD_SCOPE_ENV_KEYS
],
f"sh.CurrentDirectory = {_quote_vbs_string(working_dir)}",
# Window style 0 = hidden; bWaitOnReturn False = detached/async. The
# console python's one console is created hidden and inherited by all
Expand Down Expand Up @@ -911,8 +939,9 @@ def _spawn_detached(script_path: Path | None = None) -> int:
_assert_windows()
argv, working_dir, env_overlay = _build_gateway_argv()

# Inherit PATH etc. from the current env, overlay our required vars.
env = {**os.environ, **env_overlay}
# Inherit PATH etc. while dropping delegated-worker ownership before the
# long-lived root gateway process is created.
env = root_gateway_subprocess_env(os.environ, env_overlay)

# CREATE_NEW_PROCESS_GROUP 0x00000200 — child gets its own group, won't
# receive Ctrl+C from our group
Expand Down
189 changes: 189 additions & 0 deletions tests/hermes_cli/test_gateway_windows.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
"""Tests for hermes_cli.gateway_windows."""

import json
import os
import subprocess
import sys
import time
from pathlib import Path

import pytest
Expand Down Expand Up @@ -239,6 +244,190 @@ def test_gateway_vbs_script_is_console_less(monkeypatch):
assert content.endswith("\r\n")


_ROOT_GATEWAY_CHILD_SCOPE_MARKERS = (
"HERMES_DELEGATED_CHILD_CONTEXT",
"HERMES_KANBAN_TASK",
"HERMES_KANBAN_RUN_ID",
"HERMES_KANBAN_WORKSPACE",
"HERMES_KANBAN_BRANCH",
"HERMES_KANBAN_WORKTREE",
"HERMES_KANBAN_BOARD",
"HERMES_KANBAN_DB",
"HERMES_KANBAN_WORKSPACES_ROOT",
"HERMES_KANBAN_CLAIM_LOCK",
)


@pytest.mark.windows_only
@pytest.mark.parametrize("launcher", ["cmd", "vbs"])
def test_gateway_root_launcher_drops_child_scope_before_spawn(
launcher,
monkeypatch,
tmp_path,
):
"""Generated root launchers must not inherit delegated-worker ownership."""
fake_root = tmp_path / "fake-root"
fake_package = fake_root / "hermes_cli"
fake_package.mkdir(parents=True)
(fake_package / "__init__.py").write_text("", encoding="utf-8")
output_path = tmp_path / f"{launcher}-child-env.json"
probe_source = (
"import json, os\n"
"from pathlib import Path\n"
f"markers = {_ROOT_GATEWAY_CHILD_SCOPE_MARKERS!r}\n"
"payload = {\n"
" 'hermes_home': os.environ.get('HERMES_HOME'),\n"
" 'sentinel': os.environ.get('ROOT_GATEWAY_KEEP_ME'),\n"
" 'markers': {name: {'present': name in os.environ, 'value': os.environ.get(name)} for name in markers},\n"
"}\n"
"Path(os.environ['ROOT_GATEWAY_PROBE_OUTPUT']).write_text(json.dumps(payload), encoding='utf-8')\n"
)
(fake_package / "main.py").write_text(probe_source, encoding="utf-8")

hermes_home = tmp_path / "hermes-home"
hermes_home.mkdir()
monkeypatch.setattr(
gateway_windows,
"_resolve_detached_python",
lambda _path: (sys.executable, Path(sys.prefix), []),
)
monkeypatch.setattr(gateway_windows, "_preserve_hermes_home_path", str)
monkeypatch.setattr(
gateway_windows, "__file__", str(fake_package / "gateway_windows.py")
)
monkeypatch.setenv("ROOT_GATEWAY_PROBE_OUTPUT", str(output_path))
monkeypatch.setenv("ROOT_GATEWAY_KEEP_ME", "preserved")
for marker in _ROOT_GATEWAY_CHILD_SCOPE_MARKERS:
monkeypatch.setenv(marker, f"inherited::{marker}")

if launcher == "cmd":
content = gateway_windows._build_gateway_cmd_script(
sys.executable,
str(tmp_path),
str(hermes_home),
"",
)
script_path = tmp_path / "gateway.cmd"
argv = [os.environ.get("COMSPEC", "cmd.exe"), "/d", "/c", str(script_path)]
launch_needle = " -m "
else:
content = gateway_windows._build_gateway_vbs_script(
sys.executable,
str(tmp_path),
str(hermes_home),
"",
)
script_path = tmp_path / "gateway.vbs"
argv = ["cscript.exe", "//B", "//Nologo", str(script_path)]
launch_needle = "sh.Run "
script_path.write_bytes(content.encode("utf-8"))

completed = subprocess.run(
argv,
cwd=tmp_path,
check=False,
capture_output=True,
text=True,
timeout=20,
)
assert completed.returncode == 0, completed.stderr
deadline = time.monotonic() + 10
while not output_path.is_file() and time.monotonic() < deadline:
time.sleep(0.05)
assert output_path.is_file(), "launcher child did not write its environment probe"

observed = json.loads(output_path.read_text(encoding="utf-8"))
assert observed["hermes_home"] == str(hermes_home)
assert observed["sentinel"] == "preserved"
assert all(
not row["present"] and row["value"] is None
for row in observed["markers"].values()
)
before_launch = content[: content.index(launch_needle)]
assert all(marker in before_launch for marker in _ROOT_GATEWAY_CHILD_SCOPE_MARKERS)


@pytest.mark.windows_only
def test_spawn_detached_scrubs_child_scope_env(monkeypatch, tmp_path):
"""The direct Windows launcher must not propagate worker ownership."""
captured = []

class _FakeProcess:
pid = 4242

def _fake_popen(argv, **kwargs):
captured.append((argv, kwargs))
return _FakeProcess()

monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
monkeypatch.setattr(
gateway_windows,
"_build_gateway_argv",
lambda: (
[sys.executable, "-c", "pass"],
str(tmp_path),
{"HERMES_HOME": str(tmp_path), "ROOT_GATEWAY_OVERLAY": "preserved"},
),
)
monkeypatch.setattr(gateway_windows, "windows_detach_flags", lambda: 0)
monkeypatch.setattr(gateway_windows.subprocess, "Popen", _fake_popen)
monkeypatch.setattr(
"hermes_cli.config.get_hermes_home", lambda: str(tmp_path)
)
monkeypatch.setenv("ROOT_GATEWAY_KEEP_ME", "preserved")
for marker in _ROOT_GATEWAY_CHILD_SCOPE_MARKERS:
monkeypatch.setenv(marker, "seeded-parent-value")

assert gateway_windows._spawn_detached() == 4242

assert len(captured) == 1
child_env = captured[0][1]["env"]
assert child_env["HERMES_HOME"] == str(tmp_path)
assert child_env["ROOT_GATEWAY_OVERLAY"] == "preserved"
assert child_env["ROOT_GATEWAY_KEEP_ME"] == "preserved"
for marker in _ROOT_GATEWAY_CHILD_SCOPE_MARKERS:
assert marker not in child_env


@pytest.mark.windows_only
def test_windows_update_restart_watcher_scrubs_child_scope_env(monkeypatch):
"""The post-update watcher must receive a clean root-gateway environment."""
import hermes_cli._subprocess_compat as subprocess_compat
import hermes_cli.gateway as gateway

captured = []

def _fake_popen(argv, **kwargs):
captured.append((argv, kwargs))
return object()

monkeypatch.setattr(
gateway_windows,
"windowless_gateway_restart_spec",
lambda argv: (list(argv), "C:/hermes", {"ROOT_GATEWAY_OVERLAY": "preserved"}),
)
monkeypatch.setattr(
subprocess_compat,
"windows_detach_popen_kwargs",
lambda: {"creationflags": 0},
)
monkeypatch.setattr(gateway.subprocess, "Popen", _fake_popen)
monkeypatch.setenv("ROOT_GATEWAY_KEEP_ME", "preserved")
for marker in _ROOT_GATEWAY_CHILD_SCOPE_MARKERS:
monkeypatch.setenv(marker, "seeded-parent-value")

assert gateway._spawn_gateway_restart_watcher(
4242,
[sys.executable, "-m", "hermes_cli.main", "gateway", "run"],
)

assert len(captured) == 1
watcher_env = captured[0][1]["env"]
assert watcher_env["ROOT_GATEWAY_KEEP_ME"] == "preserved"
for marker in _ROOT_GATEWAY_CHILD_SCOPE_MARKERS:
assert marker not in watcher_env





Expand Down
Loading