394 lines
14 KiB
Python
394 lines
14 KiB
Python
"""Tests for issue #89315 — ``--replace`` must never signal a gateway it
|
|
cannot prove belongs to this HERMES_HOME.
|
|
|
|
Design contract (v3, after andrexibiza's second review): ownership is decided
|
|
by the persisted identity record ALONE — exact ``_same_hermes_home`` equality
|
|
bound to the live target by exact PID + start-time. A readable live argv
|
|
carries no HERMES_HOME, so it can never prove home ownership; it only feeds a
|
|
token-exact CONSISTENCY check that refuses explicit contradictions.
|
|
|
|
Pinned surfaces:
|
|
|
|
* record authority — valid+bound same-home allows; missing/legacy/unbound/
|
|
foreign records refuse;
|
|
* argv consistency — token-exact: ``--profile timothy`` must NOT read as
|
|
``tim`` (the substring heuristic's false-allow), while an exact different
|
|
profile flag contradicts and refuses;
|
|
* signal boundary — ``start_gateway(replace=True)`` on unprovable ownership
|
|
returns refusal without calling ``terminate_pid`` or writing a takeover
|
|
marker; the legitimate bound same-home target still reaches the replace
|
|
flow.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture()
|
|
def profile_env(tmp_path, monkeypatch):
|
|
"""Isolated HERMES_HOME mirroring tests/hermes_cli/test_profiles.py."""
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir(exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
return tmp_home if (tmp_home := default_home) else default_home
|
|
|
|
|
|
def _record(pid=424242, start=111222333, home=None, argv=None):
|
|
return {
|
|
"pid": pid,
|
|
"kind": "hermes-gateway",
|
|
"argv": argv or ["python", "-m", "hermes_cli.main", "gateway", "run"],
|
|
"start_time": start,
|
|
"hermes_home": home,
|
|
}
|
|
|
|
|
|
class TestRecordAuthority:
|
|
def test_valid_bound_same_home_record_allows(self, profile_env):
|
|
from gateway.run import _replace_target_belongs_to_other_profile
|
|
|
|
with (
|
|
patch(
|
|
"gateway.status._read_pid_record",
|
|
return_value=_record(home=str(profile_env / ".hermes")),
|
|
),
|
|
patch(
|
|
"gateway.status._get_pid_path",
|
|
return_value=profile_env / ".hermes" / "gateway.pid",
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_start_time",
|
|
return_value=111222333,
|
|
),
|
|
patch("gateway.status._read_process_cmdline", return_value=None),
|
|
patch(
|
|
"gateway.status._get_process_hermes_home",
|
|
return_value=profile_env / ".hermes",
|
|
),
|
|
):
|
|
assert _replace_target_belongs_to_other_profile(424242) is False
|
|
|
|
def test_foreign_home_record_refuses(self, profile_env):
|
|
"""Exact-home equality: another root/profile in the record refuses,
|
|
even with a bare argv that substring matching would have passed."""
|
|
from gateway.run import _replace_target_belongs_to_other_profile
|
|
|
|
with (
|
|
patch(
|
|
"gateway.status._read_pid_record",
|
|
return_value=_record(
|
|
home="/home/other/.hermes/profiles/timothy"
|
|
),
|
|
),
|
|
patch(
|
|
"gateway.status._get_pid_path",
|
|
return_value=profile_env / ".hermes" / "gateway.pid",
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_start_time",
|
|
return_value=111222333,
|
|
),
|
|
patch("gateway.status._read_process_cmdline", return_value=None),
|
|
patch(
|
|
"gateway.status._get_process_hermes_home",
|
|
return_value=profile_env / ".hermes" / "profiles" / "tim",
|
|
),
|
|
):
|
|
assert _replace_target_belongs_to_other_profile(424242) is True
|
|
|
|
def test_missing_record_refuses(self, profile_env):
|
|
"""No valid record → ownership unprovable → refuse."""
|
|
from gateway.run import _replace_target_belongs_to_other_profile
|
|
|
|
with (
|
|
patch("gateway.status._read_pid_record", return_value=None),
|
|
patch(
|
|
"gateway.status._get_pid_path",
|
|
return_value=profile_env / ".hermes" / "gateway.pid",
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_hermes_home",
|
|
return_value=profile_env / ".hermes",
|
|
),
|
|
):
|
|
assert _replace_target_belongs_to_other_profile(424242) is True
|
|
|
|
def test_legacy_record_without_home_refuses(self, profile_env):
|
|
"""A pre-hermes_home-stamping record cannot prove ownership."""
|
|
from gateway.run import _replace_target_belongs_to_other_profile
|
|
|
|
legacy = _record(home=None)
|
|
legacy.pop("hermes_home")
|
|
|
|
with (
|
|
patch(
|
|
"gateway.status._read_pid_record",
|
|
return_value=legacy,
|
|
),
|
|
patch(
|
|
"gateway.status._get_pid_path",
|
|
return_value=profile_env / ".hermes" / "gateway.pid",
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_start_time",
|
|
return_value=111222333,
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_hermes_home",
|
|
return_value=profile_env / ".hermes",
|
|
),
|
|
):
|
|
assert _replace_target_belongs_to_other_profile(424242) is True
|
|
|
|
def test_unbound_record_wrong_pid_refuses(self, profile_env):
|
|
"""A record naming a DIFFERENT pid proves nothing (poisoned shape)."""
|
|
from gateway.run import _replace_target_belongs_to_other_profile
|
|
|
|
with (
|
|
patch(
|
|
"gateway.status._read_pid_record",
|
|
return_value=_record(pid=999999, home=str(profile_env / ".hermes")),
|
|
),
|
|
patch(
|
|
"gateway.status._get_pid_path",
|
|
return_value=profile_env / ".hermes" / "gateway.pid",
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_start_time",
|
|
return_value=111222333,
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_hermes_home",
|
|
return_value=profile_env / ".hermes",
|
|
),
|
|
):
|
|
assert _replace_target_belongs_to_other_profile(424242) is True
|
|
|
|
def test_unbound_record_stale_start_time_refuses(self, profile_env):
|
|
"""PID reused since the record was written (start_time drift) → the
|
|
record no longer describes the live process → refuse."""
|
|
from gateway.run import _replace_target_belongs_to_other_profile
|
|
|
|
with (
|
|
patch(
|
|
"gateway.status._read_pid_record",
|
|
return_value=_record(start=1, home=str(profile_env / ".hermes")),
|
|
),
|
|
patch(
|
|
"gateway.status._get_pid_path",
|
|
return_value=profile_env / ".hermes" / "gateway.pid",
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_start_time",
|
|
return_value=42,
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_hermes_home",
|
|
return_value=profile_env / ".hermes",
|
|
),
|
|
):
|
|
assert _replace_target_belongs_to_other_profile(424242) is True
|
|
|
|
def test_probe_exception_fails_closed(self, profile_env):
|
|
from gateway.run import _replace_target_belongs_to_other_profile
|
|
|
|
with patch(
|
|
"gateway.status._read_pid_record",
|
|
side_effect=RuntimeError("probe exploded"),
|
|
):
|
|
assert _replace_target_belongs_to_other_profile(424242) is True
|
|
|
|
|
|
class TestArgvConsistencyCheck:
|
|
"""Readable argv is a consistency check ONLY — never authority."""
|
|
|
|
def test_prefix_collision_is_not_a_conflict(self, profile_env):
|
|
"""``--profile timothy`` must NOT read as our profile ``tim``:
|
|
substring matching would have false-allowed the foreign gateway."""
|
|
from gateway.run import (
|
|
_looks_like_profile_conflict_from_cmdline as conflict,
|
|
)
|
|
|
|
tim_home = Path("/home/x/.hermes/profiles/tim")
|
|
# Foreign target advertising timothy — NOT ours.
|
|
assert (
|
|
conflict("python -m hermes_cli.main --profile timothy gateway run", tim_home)
|
|
is True
|
|
)
|
|
# Our own exact name stays consistent.
|
|
assert (
|
|
conflict("python -m hermes_cli.main --profile tim gateway run", tim_home)
|
|
is False
|
|
)
|
|
assert (
|
|
conflict("python -m hermes_cli.main -p tim gateway run", tim_home)
|
|
is False
|
|
)
|
|
|
|
def test_explicit_home_flag_exact_compare(self, profile_env):
|
|
"""HERMES_HOME= on the argv compares path-exactly, not by prefix."""
|
|
from gateway.run import (
|
|
_looks_like_profile_conflict_from_cmdline as conflict,
|
|
)
|
|
|
|
tim_home = Path("/home/x/.hermes/profiles/tim")
|
|
assert (
|
|
conflict(
|
|
"python -m hermes_cli.main HERMES_HOME=/home/x/.hermes/profiles/timothy gateway run",
|
|
tim_home,
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
conflict(
|
|
"python -m hermes_cli.main --hermes-home /home/x/.hermes/profiles/tim/ gateway run",
|
|
tim_home,
|
|
)
|
|
is False # trailing slash normalizes away
|
|
)
|
|
|
|
def test_default_home_refuses_any_named_profile_flag(self):
|
|
from gateway.run import (
|
|
_looks_like_profile_conflict_from_cmdline as conflict,
|
|
)
|
|
|
|
root = Path("/home/x/.hermes")
|
|
assert conflict("python -m x --profile sam run", root) is True
|
|
assert conflict("python -m x -p sam run", root) is True
|
|
assert conflict("python -m x run", root) is False
|
|
|
|
def test_consistency_contradiction_refuses_even_with_agreeing_record(
|
|
self, profile_env
|
|
):
|
|
"""Record says same-home but the argv explicitly advertises another
|
|
profile → refuse (argv contradiction wins the conservative call)."""
|
|
from gateway.run import _replace_target_belongs_to_other_profile
|
|
|
|
with (
|
|
patch(
|
|
"gateway.status._read_pid_record",
|
|
return_value=_record(home=str(profile_env / ".hermes")),
|
|
),
|
|
patch(
|
|
"gateway.status._get_pid_path",
|
|
return_value=profile_env / ".hermes" / "gateway.pid",
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_start_time",
|
|
return_value=111222333,
|
|
),
|
|
patch(
|
|
"gateway.status._read_process_cmdline",
|
|
return_value="python -m hermes_cli.main --profile other-profile gateway run",
|
|
),
|
|
patch(
|
|
"gateway.status._get_process_hermes_home",
|
|
return_value=profile_env / ".hermes",
|
|
),
|
|
):
|
|
assert _replace_target_belongs_to_other_profile(424242) is True
|
|
|
|
|
|
class TestSignalBoundary:
|
|
"""Integration witness at the destructive boundary (#89315 review req)."""
|
|
|
|
def _run_replace(self, agent_patches):
|
|
from gateway import run as gateway_run
|
|
|
|
calls = {"terminate": 0, "marker": 0}
|
|
|
|
def _fake_terminate(pid, force=False):
|
|
calls["terminate"] += 1
|
|
|
|
def _fake_marker(pid):
|
|
calls["marker"] += 1
|
|
|
|
base = [
|
|
patch("gateway.status.get_running_pid", return_value=424242),
|
|
patch.object(gateway_run, "_replace_target_belongs_to_other_profile"),
|
|
patch("gateway.status.terminate_pid", side_effect=_fake_terminate),
|
|
patch("gateway.status.write_takeover_marker", side_effect=_fake_marker),
|
|
]
|
|
import contextlib
|
|
|
|
with contextlib.ExitStack() as stack:
|
|
for p in base:
|
|
stack.enter_context(p)
|
|
# caller configures the guard mock
|
|
agent_patches(stack)
|
|
try:
|
|
result = asyncio_run(gateway_run.start_gateway(replace=True))
|
|
except Exception:
|
|
result = "raised"
|
|
return result, calls
|
|
|
|
def test_unprovable_ownership_never_signals(self, profile_env):
|
|
"""Unprovable ownership → start_gateway returns False WITHOUT calling
|
|
terminate_pid or writing a takeover marker."""
|
|
from unittest.mock import MagicMock
|
|
|
|
def configure(stack):
|
|
guard = stack.enter_context(
|
|
patch(
|
|
"gateway.run._replace_target_belongs_to_other_profile",
|
|
return_value=True,
|
|
)
|
|
)
|
|
return guard
|
|
|
|
result, calls = self._run_replace(lambda s: configure(s))
|
|
|
|
assert result is False
|
|
assert calls["terminate"] == 0, (
|
|
"--replace must not signal a target whose ownership is unproven"
|
|
)
|
|
assert calls["marker"] == 0, (
|
|
"no takeover marker may be written for a refused target"
|
|
)
|
|
|
|
def test_provable_same_home_reaches_replace_flow(self, profile_env):
|
|
"""Counterpart: bound same-home target still enters the replace flow
|
|
(terminate attempted) — the fail-closed gate must not disable legit
|
|
Windows-style replaces."""
|
|
def configure(stack):
|
|
stack.enter_context(
|
|
patch(
|
|
"gateway.run._replace_target_belongs_to_other_profile",
|
|
return_value=False,
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"gateway.status.get_process_start_time",
|
|
return_value=111222333,
|
|
)
|
|
)
|
|
stack.enter_context(patch("gateway.run.time.sleep"))
|
|
|
|
result, calls = self._run_replace(configure)
|
|
|
|
assert calls["terminate"] == 1, (
|
|
"a provably same-home target must still be replaceable"
|
|
)
|
|
|
|
|
|
def asyncio_run(coro):
|
|
import asyncio
|
|
|
|
return asyncio.new_event_loop().run_until_complete(_swallow(coro))
|
|
|
|
|
|
async def _swallow(coro):
|
|
"""Run the coroutine; later machinery (runtime locks etc.) may raise in
|
|
unit context — callers inspect side-effect counters, not the outcome."""
|
|
try:
|
|
return await coro
|
|
except Exception:
|
|
return "raised"
|