179 lines
7.0 KiB
Python
179 lines
7.0 KiB
Python
"""Managed-gateway isolation for dispatcher-owned Kanban workers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
|
|
@pytest.fixture
|
|
def worker_setup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, kb.Task]:
|
|
root = tmp_path / ".hermes"
|
|
profile = root / "profiles" / "coder"
|
|
profile.mkdir(parents=True)
|
|
root.joinpath("config.yaml").write_text("{}\n", encoding="utf-8")
|
|
profile.joinpath("config.yaml").write_text("{}\n", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"])
|
|
|
|
workspace = tmp_path / "candidate-worktree"
|
|
workspace.mkdir()
|
|
task = kb.Task(
|
|
id="t_candidate_restart",
|
|
title="activate candidate",
|
|
body=None,
|
|
assignee="coder",
|
|
status="running",
|
|
priority=0,
|
|
created_by="test",
|
|
created_at=1,
|
|
started_at=1,
|
|
completed_at=None,
|
|
workspace_kind="worktree",
|
|
workspace_path=str(workspace),
|
|
claim_lock="host:dispatcher",
|
|
claim_expires=999,
|
|
tenant=None,
|
|
branch_name="wt/t_candidate_restart",
|
|
current_run_id=23,
|
|
)
|
|
return workspace, task
|
|
|
|
|
|
@pytest.mark.linux_only
|
|
def test_managed_gateway_worker_is_spawned_in_restart_safe_scope(
|
|
worker_setup: tuple[Path, kb.Task], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
workspace, task = worker_setup
|
|
captured_cmd: list[str] = []
|
|
captured_env: dict[str, str] = {}
|
|
captured_cwd: str | None = None
|
|
|
|
class FakeProc:
|
|
pid = 4242
|
|
|
|
def fake_popen(cmd, **kwargs):
|
|
nonlocal captured_cwd
|
|
captured_cmd.extend(cmd)
|
|
captured_env.update(kwargs.get("env") or {})
|
|
captured_cwd = kwargs.get("cwd")
|
|
return FakeProc()
|
|
|
|
monkeypatch.setenv("INVOCATION_ID", "managed-gateway-test")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-cross-profile")
|
|
monkeypatch.setattr("agent.secret_scope.is_multiplex_active", lambda: True)
|
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
|
monkeypatch.setattr("tools.process_registry._is_supervised_gateway_process", lambda: True)
|
|
monkeypatch.setattr("tools.process_registry._systemd_run_user_scope_available", lambda: True)
|
|
monkeypatch.setattr("tools.process_registry._worker_memory_max_bytes", lambda: 536_870_912)
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run")
|
|
|
|
assert kb._default_spawn(task, str(workspace)) == 4242
|
|
assert captured_cmd[:4] == ["/usr/bin/systemd-run", "--user", "--scope", "--quiet"]
|
|
unit_index = captured_cmd.index("--unit")
|
|
assert captured_cmd[unit_index + 1] == "hermes-worker-kanban-t_candidate_restart-run-23"
|
|
assert "MemoryMax=536870912" in captured_cmd
|
|
separator = captured_cmd.index("--")
|
|
assert captured_cmd[separator + 1 : separator + 4] == ["hermes", "-p", "coder"]
|
|
assert captured_cwd == str(workspace)
|
|
assert captured_env["HERMES_KANBAN_TASK"] == task.id
|
|
assert captured_env["HERMES_KANBAN_RUN_ID"] == "23"
|
|
assert "ANTHROPIC_API_KEY" not in captured_env
|
|
|
|
|
|
@pytest.mark.linux_only
|
|
def test_managed_gateway_worker_spawn_fails_closed_without_scope(
|
|
worker_setup: tuple[Path, kb.Task], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
workspace, task = worker_setup
|
|
popen_calls: list[list[str]] = []
|
|
monkeypatch.setenv("INVOCATION_ID", "managed-gateway-test")
|
|
monkeypatch.setattr(subprocess, "Popen", lambda cmd, **kwargs: popen_calls.append(list(cmd)))
|
|
monkeypatch.setattr("tools.process_registry._is_supervised_gateway_process", lambda: True)
|
|
monkeypatch.setattr("tools.process_registry._systemd_run_user_scope_available", lambda: False)
|
|
|
|
with pytest.raises(RuntimeError, match="restart-safe systemd scope"):
|
|
kb._default_spawn(task, str(workspace))
|
|
assert popen_calls == []
|
|
|
|
|
|
@pytest.mark.linux_only
|
|
def test_managed_gateway_scope_builder_fails_closed_if_binary_disappears(
|
|
worker_setup: tuple[Path, kb.Task], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
workspace, task = worker_setup
|
|
monkeypatch.setenv("INVOCATION_ID", "managed-gateway-test")
|
|
monkeypatch.setattr("tools.process_registry._is_supervised_gateway_process", lambda: True)
|
|
monkeypatch.setattr("tools.process_registry._systemd_run_user_scope_available", lambda: True)
|
|
monkeypatch.setattr("shutil.which", lambda _name: None)
|
|
monkeypatch.setattr(subprocess, "Popen", lambda *_args, **_kwargs: pytest.fail("unsafe direct spawn"))
|
|
|
|
with pytest.raises(RuntimeError, match="restart-safe systemd scope"):
|
|
kb._default_spawn(task, str(workspace))
|
|
|
|
|
|
def test_standalone_dispatcher_keeps_direct_worker_spawn(
|
|
worker_setup: tuple[Path, kb.Task], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
workspace, task = worker_setup
|
|
captured_cmd: list[str] = []
|
|
|
|
class FakeProc:
|
|
pid = 4243
|
|
|
|
monkeypatch.setattr(subprocess, "Popen", lambda cmd, **kwargs: captured_cmd.extend(cmd) or FakeProc())
|
|
monkeypatch.setattr("tools.process_registry._is_supervised_gateway_process", lambda: False)
|
|
monkeypatch.setattr(
|
|
"tools.process_registry._systemd_run_user_scope_available",
|
|
lambda: pytest.fail("scope probe must not run outside managed gateway"),
|
|
)
|
|
|
|
assert kb._default_spawn(task, str(workspace)) == 4243
|
|
assert captured_cmd[:3] == ["hermes", "-p", "coder"]
|
|
|
|
|
|
@pytest.mark.linux_only
|
|
def test_real_user_systemd_scope_preserves_worker_context(
|
|
worker_setup: tuple[Path, kb.Task], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from tools import process_registry
|
|
|
|
if not process_registry._systemd_run_user_scope_available():
|
|
pytest.skip("systemd-run --user --scope is unavailable on this host")
|
|
|
|
workspace, task = worker_setup
|
|
receipt = workspace / "worker-receipt.json"
|
|
script = (
|
|
"import json, os, pathlib, sys, time; "
|
|
"pathlib.Path(sys.argv[1]).write_text(json.dumps({"
|
|
"'pid': os.getpid(), 'cwd': os.getcwd(), "
|
|
"'task': os.environ.get('HERMES_KANBAN_TASK'), "
|
|
"'run': os.environ.get('HERMES_KANBAN_RUN_ID'), "
|
|
"'cgroup': pathlib.Path('/proc/self/cgroup').read_text()})); time.sleep(0.5)"
|
|
)
|
|
monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: [sys.executable, "-c", script, str(receipt)])
|
|
monkeypatch.setenv("INVOCATION_ID", "managed-gateway-test")
|
|
monkeypatch.setattr(process_registry, "_is_supervised_gateway_process", lambda: True)
|
|
|
|
pid = kb._default_spawn(task, str(workspace))
|
|
deadline = time.monotonic() + 5
|
|
while not receipt.exists() and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
|
|
assert receipt.exists()
|
|
payload = json.loads(receipt.read_text(encoding="utf-8"))
|
|
assert payload["pid"] == pid
|
|
assert payload["cwd"] == str(workspace)
|
|
assert payload["task"] == task.id
|
|
assert payload["run"] == "23"
|
|
assert ".scope" in payload["cgroup"]
|
|
assert "hermes-gateway.service" not in payload["cgroup"]
|