diff --git a/tests/hermes_cli/test_ssh_ownership_endpoint.py b/tests/hermes_cli/test_ssh_ownership_endpoint.py index ad8d21d447..ee6154ba80 100644 --- a/tests/hermes_cli/test_ssh_ownership_endpoint.py +++ b/tests/hermes_cli/test_ssh_ownership_endpoint.py @@ -1,3 +1,7 @@ +import os +import types + +import pytest from fastapi.testclient import TestClient from hermes_cli import web_server @@ -25,13 +29,23 @@ def test_ssh_ownership_endpoint_requires_token_and_returns_exact_nonce(monkeypat } -def test_ssh_ownership_reports_replaced_runtime(monkeypatch): +def test_ssh_ownership_reports_replaced_runtime(tmp_path, monkeypatch): token = "t" * 64 monkeypatch.setattr(web_server, "_SESSION_TOKEN", token) monkeypatch.setattr(web_server, "_SSH_OWNER_NONCE", "0123456789abcdef") monkeypatch.setattr(web_server, "_SSH_RUNTIME_MARKER", None) - monkeypatch.setattr(web_server, "_SSH_RUNTIME_PURELIB", ("/venv/site-packages", 10, 20)) - monkeypatch.setattr(web_server.os, "stat", lambda _path: type("Stat", (), {"st_dev": 10, "st_ino": 21})()) + # A REAL purelib file whose recorded inode deliberately mismatches what + # os.stat now reports — never patch os.stat globally here: web_server.os + # is the os module itself, and swapping its stat() poisons every other + # thread in this worker process (daemon threads from earlier tests crash + # in their excepthooks → nondeterministic teardown errors across the + # whole suite, the Aug 2026 CI flake). + purelib = tmp_path / "site-packages" + purelib.mkdir() + st = purelib.stat() + monkeypatch.setattr( + web_server, "_SSH_RUNTIME_PURELIB", (str(purelib), st.st_dev, st.st_ino + 1) + ) client = TestClient(web_server.app) response = client.get("/api/ssh/ownership", headers={"X-Hermes-Session-Token": token}) @@ -49,10 +63,14 @@ def test_ssh_runtime_marker_detects_recreated_venv_even_with_reused_inode( file is the deterministic tier: it dies with the old tree.""" purelib = tmp_path / "venv" / "lib" / "site-packages" purelib.mkdir(parents=True) + # Swap the MODULE ATTRIBUTE on web_server, not sysconfig.get_paths itself: + # sysconfig is process-global, and mutating it races every other thread + # in the worker (same cross-thread poisoning class as the os.stat patch + # this file used to have). monkeypatch.setattr( - web_server.sysconfig, - "get_paths", - lambda *a, **k: {"purelib": str(purelib)}, + web_server, + "sysconfig", + types.SimpleNamespace(get_paths=lambda *a, **k: {"purelib": str(purelib)}), ) web_server._apply_ssh_owner_nonce("0123456789abcdef") @@ -76,10 +94,14 @@ def test_ssh_runtime_marker_survives_in_place_installs(tmp_path, monkeypatch): """pip/uv installs INTO the live venv must not read as a replacement.""" purelib = tmp_path / "venv" / "lib" / "site-packages" purelib.mkdir(parents=True) + # Swap the MODULE ATTRIBUTE on web_server, not sysconfig.get_paths itself: + # sysconfig is process-global, and mutating it races every other thread + # in the worker (same cross-thread poisoning class as the os.stat patch + # this file used to have). monkeypatch.setattr( - web_server.sysconfig, - "get_paths", - lambda *a, **k: {"purelib": str(purelib)}, + web_server, + "sysconfig", + types.SimpleNamespace(get_paths=lambda *a, **k: {"purelib": str(purelib)}), ) web_server._apply_ssh_owner_nonce("0123456789abcdef") @@ -95,27 +117,33 @@ def test_ssh_runtime_readonly_purelib_falls_back_to_stat(tmp_path, monkeypatch): stat-snapshot fallback still arms (weaker, never a false stale).""" purelib = tmp_path / "venv" / "lib" / "site-packages" purelib.mkdir(parents=True) + # Swap the MODULE ATTRIBUTE on web_server, not sysconfig.get_paths itself: + # sysconfig is process-global, and mutating it races every other thread + # in the worker (same cross-thread poisoning class as the os.stat patch + # this file used to have). monkeypatch.setattr( - web_server.sysconfig, - "get_paths", - lambda *a, **k: {"purelib": str(purelib)}, + web_server, + "sysconfig", + types.SimpleNamespace(get_paths=lambda *a, **k: {"purelib": str(purelib)}), ) - real_open = open - - def refuse_marker(path, *a, **k): - if ".hermes-ssh-runtime-" in str(path): - raise OSError(30, "Read-only file system") - return real_open(path, *a, **k) - - monkeypatch.setattr("builtins.open", refuse_marker) - - web_server._apply_ssh_owner_nonce("0123456789abcdef") + # Make the directory REALLY unwritable instead of patching builtins.open: + # a global open() patch races every other thread in the worker process + # (daemon threads crash in their excepthooks → nondeterministic teardown + # errors file-wide, the Aug 2026 CI flake). chmod is thread-safe and + # exercises the genuine OSError path. + if os.geteuid() == 0: # pragma: no cover - root ignores mode bits + pytest.skip("directory write bits are not enforced for root") + purelib.chmod(0o555) try: - assert web_server._SSH_RUNTIME_MARKER is None - assert web_server._SSH_RUNTIME_PURELIB is not None - assert web_server._ssh_runtime_intact() is True + web_server._apply_ssh_owner_nonce("0123456789abcdef") + try: + assert web_server._SSH_RUNTIME_MARKER is None + assert web_server._SSH_RUNTIME_PURELIB is not None + assert web_server._ssh_runtime_intact() is True + finally: + web_server._apply_ssh_owner_nonce(None) finally: - web_server._apply_ssh_owner_nonce(None) + purelib.chmod(0o755) # let tmp_path cleanup succeed def test_ssh_ownership_endpoint_is_absent_without_owner_nonce(monkeypatch):