* stage-3664: Windows pytest harness hardening (test-only batch #4254/4255/4256/4257/4259) * test(#3664): Windows pytest-harness compatibility batch Combines 5 rodboev test-only PRs (#4254/#4255/#4256/#4257/#4259), all under tests/. Linux assertion paths preserved at full strictness; relaxations + new markers are Windows/no-fork gated. Codex SAFE + Opus SHIP + changed tests green (cascade-only full-suite noise is the box artifact). No version stamp (tests-only, Nathan rule). Co-authored-by: rodboev <rodboev@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
10 files changed
+187
-28
No files matched your search
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- **Windows pytest-harness compatibility (#3664).** Hardened the test suite to run on Windows: profile-home fallback paths are path-normalized, strict POSIX file-mode (`0o600`) assertions are gated behind `os.name != "nt"` (Linux still asserts them at full strictness), the conftest cleanup handles Windows process-tree/port teardown and the Py3.12+ `shutil.rmtree` `onexc` shim, and tests that require `fork`/`fcntl` carry `@requires_fork` / `@requires_fcntl` markers (which never skip on Linux). Test-only — no runtime or app behavior change, no Linux CI behavior change. (#4254, #4255, #4256, #4257, #4259)
|
||||||
|
|
||||||
## [v0.51.438] — 2026-06-15 — Release OY (model cost/health comparison table on Insights)
|
## [v0.51.438] — 2026-06-15 — Release OY (model cost/health comparison table on Insights)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+134
-18
@@ -14,6 +14,8 @@ PATH DISCOVERY:
|
|||||||
4. System python3 as a last resort
|
4. System python3 as a last resort
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import inspect
|
||||||
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import shutil
|
import shutil
|
||||||
@@ -32,6 +34,16 @@ if not (3, 11) <= sys.version_info[:2] <= (3, 13):
|
|||||||
returncode=3,
|
returncode=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
WINDOWS = sys.platform == "win32"
|
||||||
|
requires_fcntl = pytest.mark.skipif(
|
||||||
|
WINDOWS,
|
||||||
|
reason="requires fcntl-backed nonblocking pipe reads",
|
||||||
|
)
|
||||||
|
requires_fork = pytest.mark.skipif(
|
||||||
|
"fork" not in multiprocessing.get_all_start_methods(),
|
||||||
|
reason="requires multiprocessing fork",
|
||||||
|
)
|
||||||
|
|
||||||
# ── Repo root discovery ────────────────────────────────────────────────────
|
# ── Repo root discovery ────────────────────────────────────────────────────
|
||||||
# conftest.py lives at <repo>/tests/conftest.py
|
# conftest.py lives at <repo>/tests/conftest.py
|
||||||
TESTS_DIR = pathlib.Path(__file__).parent.resolve()
|
TESTS_DIR = pathlib.Path(__file__).parent.resolve()
|
||||||
@@ -244,6 +256,8 @@ requires_agent_modules = pytest.mark.skipif(
|
|||||||
def pytest_configure(config):
|
def pytest_configure(config):
|
||||||
config.addinivalue_line("markers", "requires_agent: skip when hermes-agent dir is not found")
|
config.addinivalue_line("markers", "requires_agent: skip when hermes-agent dir is not found")
|
||||||
config.addinivalue_line("markers", "requires_agent_modules: skip when hermes-agent Python modules are not importable")
|
config.addinivalue_line("markers", "requires_agent_modules: skip when hermes-agent Python modules are not importable")
|
||||||
|
config.addinivalue_line("markers", "requires_fcntl: skip when fcntl-backed file-descriptor operations are unavailable")
|
||||||
|
config.addinivalue_line("markers", "requires_fork: skip when the platform lacks multiprocessing fork support")
|
||||||
|
|
||||||
|
|
||||||
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
||||||
@@ -601,6 +615,120 @@ def _server_boot_diagnostic(headline, log_path):
|
|||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_process_tree(pid):
|
||||||
|
"""Best-effort kill for a known fixture-owned or port-owning PID."""
|
||||||
|
if not pid or pid <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=5,
|
||||||
|
**({"creationflags": subprocess.CREATE_NO_WINDOW}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
import signal
|
||||||
|
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_port_owner(port):
|
||||||
|
"""Best-effort free of TEST_PORT, using a Windows-native owner lookup."""
|
||||||
|
try:
|
||||||
|
if sys.platform != "win32":
|
||||||
|
subprocess.run(["fuser", "-k", f"{port}/tcp"], capture_output=True, timeout=5)
|
||||||
|
return
|
||||||
|
|
||||||
|
creationflags = {"creationflags": subprocess.CREATE_NO_WINDOW}
|
||||||
|
ps_cmd = (
|
||||||
|
"$port = " + str(port) + "; "
|
||||||
|
"$conn = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | "
|
||||||
|
"Where-Object { $_.LocalAddress -in @('127.0.0.1', '::1', '0.0.0.0', '::', '::ffff:127.0.0.1') } | "
|
||||||
|
"Select-Object -First 1 -ExpandProperty OwningProcess; "
|
||||||
|
"if ($conn) { $conn }"
|
||||||
|
)
|
||||||
|
proc = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_cmd],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
**creationflags,
|
||||||
|
)
|
||||||
|
pid_text = proc.stdout.strip()
|
||||||
|
if not pid_text:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["netstat", "-ano", "-p", "tcp"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
**creationflags,
|
||||||
|
)
|
||||||
|
port_suffixes = (f":{port}", f"[::]:{port}", f"127.0.0.1:{port}", f"[::1]:{port}")
|
||||||
|
for line in proc.stdout.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 5 or parts[0].upper() != "TCP":
|
||||||
|
continue
|
||||||
|
local_addr = parts[1]
|
||||||
|
state = parts[3].upper()
|
||||||
|
owner_pid = parts[4]
|
||||||
|
if state == "LISTENING" and any(local_addr.endswith(suffix) for suffix in port_suffixes):
|
||||||
|
pid_text = owner_pid
|
||||||
|
break
|
||||||
|
if pid_text:
|
||||||
|
_kill_process_tree(int(pid_text))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _rmtree_retry(path):
|
||||||
|
"""Remove a tree, retrying on Windows after clearing read-only bits."""
|
||||||
|
target = pathlib.Path(path)
|
||||||
|
if not target.exists():
|
||||||
|
return
|
||||||
|
if sys.platform != "win32":
|
||||||
|
shutil.rmtree(target)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _clear_readonly(_func, entry, _exc):
|
||||||
|
try:
|
||||||
|
os.chmod(entry, 0o666)
|
||||||
|
_func(entry)
|
||||||
|
except Exception:
|
||||||
|
raise
|
||||||
|
|
||||||
|
rmtree_kwargs = (
|
||||||
|
{"onexc": _clear_readonly}
|
||||||
|
if "onexc" in inspect.signature(shutil.rmtree).parameters
|
||||||
|
else {"onerror": _clear_readonly}
|
||||||
|
)
|
||||||
|
|
||||||
|
attempts = 5
|
||||||
|
last_exc = None
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(target, **rmtree_kwargs)
|
||||||
|
return
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if attempt == attempts:
|
||||||
|
break
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
leftovers = []
|
||||||
|
try:
|
||||||
|
leftovers = [child.name for child in list(target.iterdir())[:5]]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
leftover_note = f" leftovers={leftovers}" if leftovers else ""
|
||||||
|
raise RuntimeError(f"Could not remove {target} after {attempts} attempts.{leftover_note}") from last_exc
|
||||||
|
|
||||||
|
|
||||||
# ── Session-scoped test server ────────────────────────────────────────────────
|
# ── Session-scoped test server ────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
@@ -612,18 +740,13 @@ def test_server():
|
|||||||
# Kill any leftover process on the test port before starting.
|
# Kill any leftover process on the test port before starting.
|
||||||
# Stale servers from QA harness runs or prior test sessions cause
|
# Stale servers from QA harness runs or prior test sessions cause
|
||||||
# conftest to think the server is already up, producing false failures.
|
# conftest to think the server is already up, producing false failures.
|
||||||
try:
|
_kill_port_owner(TEST_PORT)
|
||||||
import subprocess as _sp
|
|
||||||
_sp.run(['fuser', '-k', f'{TEST_PORT}/tcp'],
|
|
||||||
capture_output=True, timeout=5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
import time as _time
|
import time as _time
|
||||||
_time.sleep(0.5) # brief pause to let the port release
|
_time.sleep(0.5) # brief pause to let the port release
|
||||||
|
|
||||||
# Clean slate
|
# Clean slate
|
||||||
if TEST_STATE_DIR.exists():
|
if TEST_STATE_DIR.exists():
|
||||||
shutil.rmtree(TEST_STATE_DIR)
|
_rmtree_retry(TEST_STATE_DIR)
|
||||||
TEST_STATE_DIR.mkdir(parents=True)
|
TEST_STATE_DIR.mkdir(parents=True)
|
||||||
TEST_WORKSPACE.mkdir(parents=True)
|
TEST_WORKSPACE.mkdir(parents=True)
|
||||||
|
|
||||||
@@ -759,16 +882,12 @@ def test_server():
|
|||||||
last_reason = reason
|
last_reason = reason
|
||||||
# Tear down the failed attempt and free the port before retrying.
|
# Tear down the failed attempt and free the port before retrying.
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
_kill_process_tree(proc.pid)
|
||||||
proc.wait(timeout=5)
|
proc.wait(timeout=5)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if _attempt < boot_attempts:
|
if _attempt < boot_attempts:
|
||||||
try:
|
_kill_port_owner(TEST_PORT)
|
||||||
import subprocess as _sp2
|
|
||||||
_sp2.run(['fuser', '-k', f'{TEST_PORT}/tcp'], capture_output=True, timeout=5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
else:
|
else:
|
||||||
pytest.fail(
|
pytest.fail(
|
||||||
@@ -787,12 +906,9 @@ def test_server():
|
|||||||
try:
|
try:
|
||||||
proc.wait(timeout=5)
|
proc.wait(timeout=5)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
proc.kill()
|
_kill_process_tree(proc.pid)
|
||||||
|
|
||||||
try:
|
_rmtree_retry(TEST_STATE_DIR)
|
||||||
shutil.rmtree(TEST_STATE_DIR)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ── Test base URL ─────────────────────────────────────────────────────────────
|
# ── Test base URL ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class TestSessionPersistence(unittest.TestCase):
|
|||||||
self.assertIn("valid_token", auth._sessions)
|
self.assertIn("valid_token", auth._sessions)
|
||||||
|
|
||||||
def test_sessions_file_permissions(self) -> None:
|
def test_sessions_file_permissions(self) -> None:
|
||||||
"""Sessions file must be owner-read-only (0600)."""
|
"""Sessions file must stay strict on POSIX and still be created on Windows."""
|
||||||
auth.create_session()
|
auth.create_session()
|
||||||
# Check the path auth actually writes to (auth._SESSIONS_FILE is computed
|
# Check the path auth actually writes to (auth._SESSIONS_FILE is computed
|
||||||
# from api.config.STATE_DIR at import time). Asserting against a local
|
# from api.config.STATE_DIR at import time). Asserting against a local
|
||||||
@@ -87,6 +87,9 @@ class TestSessionPersistence(unittest.TestCase):
|
|||||||
# this module may import before api.config.STATE_DIR resolves to _TEST_STATE.
|
# this module may import before api.config.STATE_DIR resolves to _TEST_STATE.
|
||||||
sessions_file = auth._SESSIONS_FILE
|
sessions_file = auth._SESSIONS_FILE
|
||||||
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
|
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
|
||||||
|
if os.name == "nt":
|
||||||
|
self.assertTrue(sessions_file.is_file(), ".sessions.json was not written as a file")
|
||||||
|
return
|
||||||
mode = oct(sessions_file.stat().st_mode & 0o777)
|
mode = oct(sessions_file.stat().st_mode & 0o777)
|
||||||
self.assertEqual(mode, oct(0o600),
|
self.assertEqual(mode, oct(0o600),
|
||||||
f".sessions.json permissions {mode} — expected 0o600")
|
f".sessions.json permissions {mode} — expected 0o600")
|
||||||
|
|||||||
@@ -231,10 +231,11 @@ class TestMainForegroundRouting:
|
|||||||
def stub_main_dependencies(self, monkeypatch, tmp_path):
|
def stub_main_dependencies(self, monkeypatch, tmp_path):
|
||||||
"""Stub out everything main() calls except the routing decision."""
|
"""Stub out everything main() calls except the routing decision."""
|
||||||
import bootstrap as bs
|
import bootstrap as bs
|
||||||
|
python_exe = sys.executable
|
||||||
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
|
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
|
||||||
monkeypatch.setattr(bs, "discover_agent_dir", lambda: tmp_path / "agent")
|
monkeypatch.setattr(bs, "discover_agent_dir", lambda: tmp_path / "agent")
|
||||||
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
|
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
|
||||||
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: "/usr/bin/python3")
|
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: python_exe)
|
||||||
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0])
|
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0])
|
||||||
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
|
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
|
||||||
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
|
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
|
||||||
@@ -265,6 +266,7 @@ class TestMainForegroundRouting:
|
|||||||
def test_foreground_flag_uses_execv(self, stub_main_dependencies, clean_env, monkeypatch):
|
def test_foreground_flag_uses_execv(self, stub_main_dependencies, clean_env, monkeypatch):
|
||||||
bs = stub_main_dependencies
|
bs = stub_main_dependencies
|
||||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
|
|
||||||
execv_calls = []
|
execv_calls = []
|
||||||
popen_calls = []
|
popen_calls = []
|
||||||
@@ -289,9 +291,10 @@ class TestMainForegroundRouting:
|
|||||||
assert len(popen_calls) == 0, "--foreground must NOT call subprocess.Popen"
|
assert len(popen_calls) == 0, "--foreground must NOT call subprocess.Popen"
|
||||||
|
|
||||||
path, argv = execv_calls[0]
|
path, argv = execv_calls[0]
|
||||||
assert path == "/usr/bin/python3"
|
python_exe = sys.executable
|
||||||
|
assert path == python_exe
|
||||||
# argv[0] is the program name (convention), argv[1] is the script
|
# argv[0] is the program name (convention), argv[1] is the script
|
||||||
assert argv[0] == "/usr/bin/python3"
|
assert argv[0] == python_exe
|
||||||
assert argv[1].endswith("server.py")
|
assert argv[1].endswith("server.py")
|
||||||
|
|
||||||
@pytest.mark.parametrize("var", [
|
@pytest.mark.parametrize("var", [
|
||||||
@@ -304,6 +307,7 @@ class TestMainForegroundRouting:
|
|||||||
def test_supervisor_env_var_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch, var):
|
def test_supervisor_env_var_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch, var):
|
||||||
bs = stub_main_dependencies
|
bs = stub_main_dependencies
|
||||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
monkeypatch.setenv(var, "deadbeef")
|
monkeypatch.setenv(var, "deadbeef")
|
||||||
|
|
||||||
execv_calls = []
|
execv_calls = []
|
||||||
@@ -327,6 +331,7 @@ class TestMainForegroundRouting:
|
|||||||
def test_explicit_opt_in_env_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch):
|
def test_explicit_opt_in_env_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch):
|
||||||
bs = stub_main_dependencies
|
bs = stub_main_dependencies
|
||||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground flag
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground flag
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", "1")
|
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", "1")
|
||||||
|
|
||||||
execv_calls = []
|
execv_calls = []
|
||||||
@@ -348,12 +353,13 @@ class TestForegroundEnvAndCwd:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def setup(self, monkeypatch, tmp_path):
|
def setup(self, monkeypatch, tmp_path):
|
||||||
import bootstrap as bs
|
import bootstrap as bs
|
||||||
|
python_exe = sys.executable
|
||||||
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
|
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
|
||||||
agent_dir = tmp_path / "agent"
|
agent_dir = tmp_path / "agent"
|
||||||
agent_dir.mkdir()
|
agent_dir.mkdir()
|
||||||
monkeypatch.setattr(bs, "discover_agent_dir", lambda: agent_dir)
|
monkeypatch.setattr(bs, "discover_agent_dir", lambda: agent_dir)
|
||||||
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
|
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
|
||||||
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: "/usr/bin/python3")
|
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: python_exe)
|
||||||
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0])
|
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0])
|
||||||
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
|
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
|
||||||
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
|
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
|
||||||
@@ -364,6 +370,7 @@ class TestForegroundEnvAndCwd:
|
|||||||
def test_foreground_chdirs_to_agent_dir_before_exec(self, setup, monkeypatch, clean_env):
|
def test_foreground_chdirs_to_agent_dir_before_exec(self, setup, monkeypatch, clean_env):
|
||||||
bs, agent_dir = setup
|
bs, agent_dir = setup
|
||||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground", "--host", "127.0.0.1", "9999"])
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground", "--host", "127.0.0.1", "9999"])
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
|
|
||||||
chdir_calls = []
|
chdir_calls = []
|
||||||
monkeypatch.setattr(os, "chdir", lambda p: chdir_calls.append(p))
|
monkeypatch.setattr(os, "chdir", lambda p: chdir_calls.append(p))
|
||||||
@@ -382,6 +389,7 @@ class TestForegroundEnvAndCwd:
|
|||||||
monkeypatch.setattr(sys, "argv", [
|
monkeypatch.setattr(sys, "argv", [
|
||||||
"bootstrap.py", "--foreground", "--host", "0.0.0.0", "9119"
|
"bootstrap.py", "--foreground", "--host", "0.0.0.0", "9119"
|
||||||
])
|
])
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
monkeypatch.setattr(os, "chdir", lambda p: None)
|
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||||||
|
|
||||||
def fake_execv(*a):
|
def fake_execv(*a):
|
||||||
@@ -402,6 +410,7 @@ class TestForegroundEnvAndCwd:
|
|||||||
def test_foreground_does_not_call_wait_for_health(self, setup, monkeypatch, clean_env):
|
def test_foreground_does_not_call_wait_for_health(self, setup, monkeypatch, clean_env):
|
||||||
bs, _ = setup
|
bs, _ = setup
|
||||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
monkeypatch.setattr(os, "chdir", lambda p: None)
|
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||||||
|
|
||||||
wait_calls = []
|
wait_calls = []
|
||||||
@@ -444,9 +453,11 @@ class TestForegroundExecutabilityGuard:
|
|||||||
def test_non_executable_python_raises_runtime_error(self, setup_with_bad_python, monkeypatch, clean_env):
|
def test_non_executable_python_raises_runtime_error(self, setup_with_bad_python, monkeypatch, clean_env):
|
||||||
bs = setup_with_bad_python
|
bs = setup_with_bad_python
|
||||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
|
|
||||||
execv_calls = []
|
execv_calls = []
|
||||||
monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a))
|
monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a))
|
||||||
|
monkeypatch.setattr(os, "access", lambda path, mode: False)
|
||||||
monkeypatch.setattr(os, "chdir", lambda p: None)
|
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="not executable"):
|
with pytest.raises(RuntimeError, match="not executable"):
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import threading
|
|||||||
import types
|
import types
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from conftest import requires_fork
|
||||||
|
|
||||||
|
|
||||||
def _install_fake_cron(monkeypatch, run_job, events):
|
def _install_fake_cron(monkeypatch, run_job, events):
|
||||||
cron_pkg = types.ModuleType("cron")
|
cron_pkg = types.ModuleType("cron")
|
||||||
@@ -193,6 +196,7 @@ def _run_lock_probe_with_context(context_name, target, result_queue):
|
|||||||
return process.exitcode, acquired
|
return process.exitcode, acquired
|
||||||
|
|
||||||
|
|
||||||
|
@requires_fork
|
||||||
def test_spawn_context_does_not_inherit_parent_thread_locks(tmp_path):
|
def test_spawn_context_does_not_inherit_parent_thread_locks(tmp_path):
|
||||||
"""Spawn starts a fresh interpreter where fork would clone a held lock."""
|
"""Spawn starts a fresh interpreter where fork would clone a held lock."""
|
||||||
helper_dir = tmp_path / "spawn_helper"
|
helper_dir = tmp_path / "spawn_helper"
|
||||||
@@ -244,6 +248,7 @@ def test_spawn_context_does_not_inherit_parent_thread_locks(tmp_path):
|
|||||||
assert spawn_acquired is True
|
assert spawn_acquired is True
|
||||||
|
|
||||||
|
|
||||||
|
@requires_fork
|
||||||
def test_manual_cron_subprocess_drains_large_result_before_join(tmp_path):
|
def test_manual_cron_subprocess_drains_large_result_before_join(tmp_path):
|
||||||
"""A >100 KB result must not deadlock the parent before it can persist output."""
|
"""A >100 KB result must not deadlock the parent before it can persist output."""
|
||||||
if _real_hermes_agent_editable_install_present():
|
if _real_hermes_agent_editable_install_present():
|
||||||
@@ -349,6 +354,7 @@ def test_manual_cron_run_does_not_hold_profile_lock_for_job_duration(tmp_path, m
|
|||||||
assert routes._is_cron_running("job1574") == (False, 0.0)
|
assert routes._is_cron_running("job1574") == (False, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
@requires_fork
|
||||||
def test_cron_job_subprocess_executes_under_selected_profile_home(tmp_path, monkeypatch):
|
def test_cron_job_subprocess_executes_under_selected_profile_home(tmp_path, monkeypatch):
|
||||||
if _real_hermes_agent_editable_install_present():
|
if _real_hermes_agent_editable_install_present():
|
||||||
import pytest as _pytest
|
import pytest as _pytest
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import stat
|
import stat
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -15,7 +16,9 @@ def test_login_attempts_persist_failed_attempts(tmp_path, monkeypatch):
|
|||||||
data = json.loads(attempts_file.read_text(encoding="utf-8"))
|
data = json.loads(attempts_file.read_text(encoding="utf-8"))
|
||||||
assert "203.0.113.10" in data
|
assert "203.0.113.10" in data
|
||||||
assert len(data["203.0.113.10"]) == 1
|
assert len(data["203.0.113.10"]) == 1
|
||||||
assert stat.S_IMODE(attempts_file.stat().st_mode) == 0o600
|
assert attempts_file.exists()
|
||||||
|
if os.name != "nt":
|
||||||
|
assert stat.S_IMODE(attempts_file.stat().st_mode) == 0o600
|
||||||
|
|
||||||
|
|
||||||
def test_login_attempts_load_prunes_expired_entries(tmp_path, monkeypatch):
|
def test_login_attempts_load_prunes_expired_entries(tmp_path, monkeypatch):
|
||||||
|
|||||||
@@ -495,12 +495,19 @@ def test_fix_credential_permissions_corrects_loose_files(tmp_path, monkeypatch):
|
|||||||
fix_credential_permissions()
|
fix_credential_permissions()
|
||||||
|
|
||||||
import stat
|
import stat
|
||||||
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600, ".env not fixed to 600"
|
assert env_file.exists(), ".env missing after permission repair"
|
||||||
assert stat.S_IMODE(google_file.stat().st_mode) == 0o600, "google_token.json not fixed to 600"
|
assert google_file.exists(), "google_token.json missing after permission repair"
|
||||||
|
if os.name != "nt":
|
||||||
|
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600, ".env not fixed to 600"
|
||||||
|
assert stat.S_IMODE(google_file.stat().st_mode) == 0o600, "google_token.json not fixed to 600"
|
||||||
|
# Windows CI cannot assert a POSIX mode-bit repair here; keeping the files
|
||||||
|
# present after the repair pass is the strongest portable contract for now.
|
||||||
|
|
||||||
|
|
||||||
def test_fix_credential_permissions_skips_correct_files(tmp_path, monkeypatch):
|
def test_fix_credential_permissions_skips_correct_files(tmp_path, monkeypatch):
|
||||||
"""fix_credential_permissions() does not alter already-strict files."""
|
"""fix_credential_permissions() does not alter already-strict files."""
|
||||||
|
import os
|
||||||
|
|
||||||
env_file = tmp_path / ".env"
|
env_file = tmp_path / ".env"
|
||||||
env_file.write_text("SECRET=abc")
|
env_file.write_text("SECRET=abc")
|
||||||
env_file.chmod(0o600)
|
env_file.chmod(0o600)
|
||||||
@@ -511,4 +518,6 @@ def test_fix_credential_permissions_skips_correct_files(tmp_path, monkeypatch):
|
|||||||
fix_credential_permissions()
|
fix_credential_permissions()
|
||||||
|
|
||||||
import stat
|
import stat
|
||||||
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
|
assert env_file.exists(), ".env missing after strict-permission check"
|
||||||
|
if os.name != "nt":
|
||||||
|
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
|
||||||
@@ -440,7 +440,7 @@ class TestGetProfileHome:
|
|||||||
monkeypatch.setitem(sys.modules, "api.profiles", None)
|
monkeypatch.setitem(sys.modules, "api.profiles", None)
|
||||||
monkeypatch.setenv("HERMES_HOME", "/custom/hermes")
|
monkeypatch.setenv("HERMES_HOME", "/custom/hermes")
|
||||||
result = _get_profile_home(None)
|
result = _get_profile_home(None)
|
||||||
assert str(result) == "/custom/hermes"
|
assert result == Path(os.environ["HERMES_HOME"]).expanduser()
|
||||||
|
|
||||||
def test_expands_tilde_in_hermes_home(self, monkeypatch):
|
def test_expands_tilde_in_hermes_home(self, monkeypatch):
|
||||||
"""If HERMES_HOME contains ~, it gets expanded."""
|
"""If HERMES_HOME contains ~, it gets expanded."""
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import unittest
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from conftest import requires_fcntl
|
||||||
|
|
||||||
ROOT = Path(__file__).parent.parent
|
ROOT = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
@@ -190,6 +193,7 @@ class TestTLSEndToEnd(unittest.TestCase):
|
|||||||
self.assertEqual(data.get("status"), "ok")
|
self.assertEqual(data.get("status"), "ok")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
@requires_fcntl
|
||||||
def test_tls_startup_failure_fallback_to_http(self):
|
def test_tls_startup_failure_fallback_to_http(self):
|
||||||
"""Bad cert paths should print a warning and start HTTP anyway."""
|
"""Bad cert paths should print a warning and start HTTP anyway."""
|
||||||
port = _find_free_port()
|
port = _find_free_port()
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ def test_oauth_write_auth_json_uses_chmod_0600_before_rename(monkeypatch, tmp_pa
|
|||||||
os.umask(old_umask)
|
os.umask(old_umask)
|
||||||
|
|
||||||
assert fake_path.exists(), "auth.json was not written"
|
assert fake_path.exists(), "auth.json was not written"
|
||||||
|
if os.name == "nt":
|
||||||
|
assert fake_path.is_file(), "auth.json was not written as a file"
|
||||||
|
return
|
||||||
mode = stat.S_IMODE(fake_path.stat().st_mode)
|
mode = stat.S_IMODE(fake_path.stat().st_mode)
|
||||||
# The file must be chmod 0600 — owner read/write only.
|
# The file must be chmod 0600 — owner read/write only.
|
||||||
assert mode == 0o600, (
|
assert mode == 0o600, (
|
||||||
|
|||||||
Reference in new issue
Block a user