"""Regression tests for issue #68483. Running a state-writing ``hermes cron`` CLI command as root (the default for ``docker exec``) rewrote ``jobs.json`` as ``root:root`` mode 600 via the mkstemp + atomic_replace pattern, silently locking out the unprivileged gateway ticker — which then failed every tick with PermissionError while the liveness heartbeat stayed fresh, so nothing surfaced the outage. Two behavior contracts are pinned here: 1. Ownership preservation: a privileged (euid 0) writer must hand ownership of the rewritten ``jobs.json`` back to its previous owner; unprivileged writers must not attempt a chown at all. 2. Zombie-ticker surfacing: a failing tick must persist the failure reason (``ticker_last_error``) where ``hermes cron status`` can show it, and a subsequent clean tick must clear it. """ import os import sys import threading import pytest import cron.jobs as jobs pytestmark = pytest.mark.skipif( sys.platform == "win32", reason="POSIX-only: uid/gid ownership semantics" ) @pytest.fixture() def cron_store(tmp_path, monkeypatch): """Route the cron store to an isolated temp dir.""" cron_dir = tmp_path / "cron" monkeypatch.setattr(jobs, "CRON_DIR", cron_dir) monkeypatch.setattr(jobs, "JOBS_FILE", cron_dir / "jobs.json") monkeypatch.setattr(jobs, "OUTPUT_DIR", cron_dir / "output") return cron_dir # ========================================================================= # 1. Ownership preservation on save (root writer) # ========================================================================= class TestSaveJobsOwnershipPreservation: def test_root_writer_restores_previous_owner(self, cron_store, monkeypatch): """When euid==0 and jobs.json was owned by another uid/gid, the rewrite must chown the new file back to that owner.""" jobs.save_jobs([{"id": "seed", "prompt": "hello"}]) jobs_file = cron_store / "jobs.json" assert jobs_file.exists() chown_calls = [] # Pretend the existing file is owned by the gateway user (uid 1000) # and that WE are root. real_stat = os.stat class _FakeStat: def __init__(self, wrapped): self._wrapped = wrapped self.st_uid = 1000 self.st_gid = 1000 def __getattr__(self, name): return getattr(self._wrapped, name) def fake_stat(path, *a, **k): result = real_stat(path, *a, **k) if str(path) == str(jobs_file): return _FakeStat(result) return result monkeypatch.setattr(jobs.os, "stat", fake_stat) monkeypatch.setattr(jobs.os, "geteuid", lambda: 0) monkeypatch.setattr(jobs.os, "getegid", lambda: 0) monkeypatch.setattr( jobs.os, "chown", lambda path, uid, gid: chown_calls.append((str(path), uid, gid)) ) jobs.save_jobs([{"id": "seed", "prompt": "updated"}]) assert chown_calls == [(str(jobs_file), 1000, 1000)], ( "root rewrite must hand jobs.json back to the previous owner " "(uid/gid 1000) instead of leaving it root:600 (#68483)" ) def test_unprivileged_writer_never_chowns(self, cron_store, monkeypatch): """A same-uid (non-root) writer must not attempt chown at all — it would raise EPERM for foreign-owned files anyway.""" jobs.save_jobs([{"id": "seed", "prompt": "hello"}]) def _fail_chown(*a, **k): raise AssertionError("unprivileged save must not call os.chown") monkeypatch.setattr(jobs.os, "chown", _fail_chown) jobs.save_jobs([{"id": "seed", "prompt": "updated"}]) # must not raise def test_chown_failure_never_breaks_save(self, cron_store, monkeypatch): """A chown failure is logged, but the save itself must succeed.""" jobs.save_jobs([{"id": "seed", "prompt": "hello"}]) jobs_file = cron_store / "jobs.json" real_stat = os.stat class _FakeStat: def __init__(self, wrapped): self._wrapped = wrapped self.st_uid = 1000 self.st_gid = 1000 def __getattr__(self, name): return getattr(self._wrapped, name) def fake_stat(path, *a, **k): result = real_stat(path, *a, **k) if str(path) == str(jobs_file): return _FakeStat(result) return result def _broken_chown(*a, **k): raise PermissionError("simulated chown failure") monkeypatch.setattr(jobs.os, "stat", fake_stat) monkeypatch.setattr(jobs.os, "geteuid", lambda: 0) monkeypatch.setattr(jobs.os, "getegid", lambda: 0) monkeypatch.setattr(jobs.os, "chown", _broken_chown) jobs.save_jobs([{"id": "seed", "prompt": "updated"}]) # must not raise assert jobs.load_jobs()[0]["prompt"] == "updated" def test_save_still_enforces_0600(self, cron_store): """The ownership fix must not regress the 0600 hardening.""" import stat jobs.save_jobs([{"id": "seed", "prompt": "hello"}]) mode = stat.S_IMODE(os.stat(cron_store / "jobs.json").st_mode) assert mode == 0o600 # ========================================================================= # 2. Zombie-ticker surfacing (ticker_last_error marker) # ========================================================================= class TestTickerErrorMarker: def test_record_and_get_roundtrip(self, cron_store): assert jobs.get_ticker_last_error() is None jobs.record_ticker_error( "RuntimeError: Failed to read cron database: " "[Errno 13] Permission denied: '/opt/data/cron/jobs.json'" ) msg = jobs.get_ticker_last_error() assert msg is not None assert "Permission denied" in msg def test_clear_removes_marker(self, cron_store): jobs.record_ticker_error("RuntimeError: boom") assert jobs.get_ticker_last_error() is not None jobs.clear_ticker_error() assert jobs.get_ticker_last_error() is None class TestTickerLoopRecordsErrors: def _run_one_tick(self, monkeypatch, tick_fn): """Run one iteration of the built-in ticker loop with a stubbed tick.""" from cron.scheduler_provider import InProcessCronScheduler provider = InProcessCronScheduler() monkeypatch.setattr(provider, "recover_interrupted", lambda: 0) monkeypatch.setattr("cron.scheduler.tick", tick_fn) stop_event = threading.Event() original_wait = stop_event.wait def _stop_after_first_tick(timeout=None): stop_event.set() return original_wait(0) stop_event.wait = _stop_after_first_tick provider.start(stop_event) def test_failing_tick_persists_error(self, cron_store, monkeypatch): def _boom(**kwargs): raise RuntimeError( "Failed to read cron database: [Errno 13] Permission denied" ) self._run_one_tick(monkeypatch, _boom) msg = jobs.get_ticker_last_error() assert msg is not None, ( "a failing tick must persist its reason for `hermes cron status` " "to surface (#68483)" ) assert "Permission denied" in msg def test_successful_tick_clears_error(self, cron_store, monkeypatch): jobs.record_ticker_error("RuntimeError: stale failure") self._run_one_tick(monkeypatch, lambda **kwargs: None) assert jobs.get_ticker_last_error() is None, ( "a clean tick must clear the stale error marker" ) # ========================================================================= # 3. `hermes cron status` surfaces the failure reason # ========================================================================= class TestCronStatusSurfacesError: def test_status_shows_last_error_and_permission_hint(self, monkeypatch, capsys): from hermes_cli import cron as cron_cli monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda: [4321]) monkeypatch.setattr(jobs, "get_ticker_heartbeat_age", lambda: 5.0) # alive monkeypatch.setattr(jobs, "get_ticker_success_age", lambda: 9_999.0) # failing monkeypatch.setattr( jobs, "get_ticker_last_error", lambda: ( "RuntimeError: Failed to read cron database: " "[Errno 13] Permission denied: '/opt/data/cron/jobs.json'" ), ) monkeypatch.setattr("cron.jobs.list_jobs", lambda **k: []) cron_cli.cron_status() out = capsys.readouterr().out assert "Last tick error:" in out assert "Permission denied" in out # The permission-specific hint must point at the ownership fix. assert "docker exec -u" in out