From 9211e0e5edf0e658be14c6d12e7c29cfd44bed0f Mon Sep 17 00:00:00 2001 From: texasich Date: Mon, 10 Aug 2026 00:05:38 -0500 Subject: [PATCH] fix(gateway): scrub worker scope from Windows root launches Prevent Windows root gateway processes from inheriting delegated and Kanban worker ownership markers through generated CMD/VBS launchers, direct detached spawning, and the post-update restart watcher. Reuse the authoritative delegation-context marker definitions and preserve unrelated environment values. --- hermes_cli/gateway.py | 10 +- hermes_cli/gateway_windows.py | 33 +++- tests/hermes_cli/test_gateway_windows.py | 189 +++++++++++++++++++++++ 3 files changed, 229 insertions(+), 3 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 06144650cb10f..208784fcb6526 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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)) ) @@ -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 @@ -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, diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index 4d09ab38ffbd2..ed02f9dbf781a 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -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, @@ -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. @@ -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: @@ -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 @@ -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 diff --git a/tests/hermes_cli/test_gateway_windows.py b/tests/hermes_cli/test_gateway_windows.py index b8c4a5c5b39df..0218b701b16e7 100644 --- a/tests/hermes_cli/test_gateway_windows.py +++ b/tests/hermes_cli/test_gateway_windows.py @@ -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 @@ -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 + +